Skip to content
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
/domains/observability/skills/*/repos/metamask-extension.md @MetaMask/extension-platform
/domains/observability/skills/*/repos/metamask-mobile.md @MetaMask/mobile-platform
/domains/performance/ @MetaMask/extension-platform @MetaMask/mobile-platform
/domains/platform/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform
/domains/perps/ @MetaMask/perps
/domains/pr-workflow/ @MetaMask/extension-platform @MetaMask/mobile-platform
/domains/swaps/ @MetaMask/swaps-engineers
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `analytics` skill (`platform/analytics`, moved from `coding`) with a repo-agnostic base and a MetaMask Mobile overlay for the canonical tracking API. Marked `base: true` so it installs even when its domain is filtered out. ([#140](https://github.com/MetaMask/skills/pull/140))

## [0.3.1]

### Fixed
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ tools/
| -------------- | ----------------- | ------------------------------------------- |
| `web3-tools` | dApp builders | `gator-cli`, `smart-accounts-kit`, `oh-my-opencode` |
| `coding` | MM product eng | Coding guidelines, controller patterns |
| `platform` | MM product eng | Product analytics and other platform skills |
| `agentic` | MM product eng | Experimental recipe workflows and runtime proof tools |
| `assets` | MM product eng | Assets domain skills |
| `general` | All agents | `codex`, `gemini` CLI usage guides |
Expand Down Expand Up @@ -386,6 +387,12 @@ Extra metadata blocks (e.g. OpenClaw-style `metadata:` with emoji and
homepage) are preserved through install — only `name`, `description`,
`maturity`, `base`, and `scope` are read by the CLI.

`base: true` installs the skill even when its domain is filtered out.
`--exclude` / `SKILLS_EXCLUDE` still wins. The maturity filter runs before the
base bypass, so `--maturity stable` drops a `base: true` experimental skill.
A skill with a `repos/` directory and no overlay for `--repo` is skipped
(this `analytics` skill installs for Mobile and is skipped for Extension).

The 1,536-character ceiling is a repo budget rather than an operator limit — the
description is always-on context for every installed skill, so it is capped
deliberately. It is enforced by `yarn audit:skills` from
Expand Down
194 changes: 194 additions & 0 deletions domains/platform/skills/analytics/repos/metamask-mobile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
---
repo: metamask-mobile
parent: analytics
---

# Analytics — MetaMask Mobile

Human-facing file map: `app/core/Analytics/README.md`. A/B enrichment SSOT: `docs/ab-testing.md`.

## Canonical API

| Role | Path |
|------|------|
| Helper (non-React) | `app/util/analytics/analytics.ts` → `analytics.trackEvent` |
| Helper (UI) | `app/components/hooks/useAnalytics/useAnalytics.ts` → `useAnalytics` |
| Engine (controllers) | `app/core/Engine/utils/analytics.ts` → `trackEvent`, `buildAndTrackEvent` |
| Event builder | `app/util/analytics/AnalyticsEventBuilder.ts` → `AnalyticsEventBuilder.createEventBuilder` |
| Catalog | `app/core/Analytics/` → `MetaMetricsEvents` at call sites; `EVENT_NAME` in catalog modules |
| Typed helpers | `app/util/analytics/actionButtonTracking.ts` (sibling files matching `*Tracking.ts`) |
| A/B registry | `app/util/analytics/abTestAnalyticsRegistry.ts` (feature-local `abTestConfig.ts`, e.g. `app/components/Views/Homepage/abTestConfig.ts`) |
| Test factory | `app/util/test/analyticsMock.ts` → `createMockUseAnalyticsHook` (default); `createMockEventBuilder` (optional standalone double) |

`useAnalytics()` returns `trackEvent`, `createEventBuilder`, `identify`, `enable`,
`isEnabled`, `getAnalyticsId`, and data-deletion helpers.

Controllers that already talk to Engine import `trackEvent` / `buildAndTrackEvent`
from `app/core/Engine/utils/analytics.ts`. Those helpers always wrap the
messenger call in try/catch. `enrichWithABTests` runs only when the event name
is registered in `app/util/analytics/abTestAnalyticsRegistry.ts` (fed by
feature-local `abTestConfig.ts`). New experiment events: follow
`docs/ab-testing.md`. Do not copy Engine-util internals.

`createMockEventBuilder()` default `build()` is
`{ name: 'mock-event', properties: {}, sensitiveProperties: {} }`. Use it only
as a standalone builder double, wrapped in `jest.fn(() => createMockEventBuilder())`.

## Requirements

- UI: platform `useAnalytics` from `app/components/hooks/useAnalytics/useAnalytics.ts`
- Non-React: `analytics.trackEvent`
- Controllers: `trackEvent` / `buildAndTrackEvent` from `app/core/Engine/utils/analytics.ts`
- When a typed helper exists in `app/util/analytics/` (files matching `*Tracking.ts`) for this event, call it
- Call sites (new and existing) import `MetaMetricsEvents.*`. Register new names as `EVENT_NAME` + `generateOpt` in catalog modules, then emit via `MetaMetricsEvents`. Reuse a catalog name only when this control is the same interaction as existing call sites (same event, same product meaning).
- Properties via `.addProperties(...).build()`
- UI tests: `createMockUseAnalyticsHook` wrapping `useAnalytics`, including when the file already mocks the hook. Default: `createMockUseAnalyticsHook({ trackEvent: mockTrackEvent })`. Tests that assert `addProperties` keep `AnalyticsEventBuilder.createEventBuilder`
- Non-React tests: assert `AnalyticsEventBuilder.createEventBuilder` and `analytics.trackEvent` or Engine `trackEvent` / `buildAndTrackEvent`

Generic UI (`app/components/UI/BalanceEmptyState/BalanceEmptyState.tsx`):

```ts
import React from 'react';
import { MetaMetricsEvents } from '../../../core/Analytics';
import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics';

const BalanceEmptyState: React.FC<BalanceEmptyStateProps> = ({
testID = 'balance-empty-state',
...props
}) => {
const { trackEvent, createEventBuilder } = useAnalytics();

const handleAction = () => {
trackEvent(
createEventBuilder(MetaMetricsEvents.RAMPS_BUTTON_CLICKED)
.addProperties({
button_text: 'Add funds',
location: 'BalanceEmptyState',
ramp_type: 'UNIFIED_BUY_2',
})
.build(),
);
};
```

Typed helper (`app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SendButton.tsx`):

```ts
import React, { useCallback } from 'react';
import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
import {
ActionButtonType,
ActionLocation,
trackActionButtonClick,
} from '../../../../../../util/analytics/actionButtonTracking';

const SendButton = ({
actionPosition,
allowTwoLineLabel,
onSend,
}: SendButtonProps) => {
const { trackEvent, createEventBuilder } = useAnalytics();

const handlePress = useCallback(() => {
trackActionButtonClick(trackEvent, createEventBuilder, {
action_name: ActionButtonType.SEND,
action_position: actionPosition,
button_label: label,
location: ActionLocation.HOME,
});
onSend();
}, [actionPosition, createEventBuilder, label, onSend, trackEvent]);
```
Comment thread
NicolasMassart marked this conversation as resolved.

Non-React (`app/util/analytics/accountAccessTracking.ts`):

```ts
import { MetaMetricsEvents } from '../../core/Analytics/MetaMetrics.events';
import { analytics } from './analytics';
import { AnalyticsEventBuilder } from './AnalyticsEventBuilder';

analytics.trackEvent(
AnalyticsEventBuilder.createEventBuilder(
MetaMetricsEvents.APP_UNLOCKED_FAILED,
)
.addProperties({
unlock_error_type: unlockErrorType,
forced_reset: forcedReset,
})
.build(),
);
```

Controllers:

```ts
import { buildAndTrackEvent } from '../../core/Engine/utils/analytics';
import { MetaMetricsEvents } from '../../core/Analytics';

buildAndTrackEvent(
initMessenger,
MetaMetricsEvents.PROFILE_ACTIVITY_UPDATED.category,
{
profile_id: profileId,
feature_name: 'Contacts Sync',
action: 'Contacts Sync Contact Updated',
},
);
```

`createEventBuilder` copies only `category` from `IMetaMetricsEvent`. When
migrating a wrapper that used `generateOpt(name, action, description)`, re-apply
`properties.action` and `properties.name` with `addProperties`.

`generateOpt` belongs in catalog modules: `app/core/Analytics/MetaMetrics.events.ts`,
`app/core/Analytics/events/`, and feature-local `<feature>/analytics/events.ts`
(see SampleFeature). Component files import catalog entries; they do not call
`generateOpt` themselves.

Tests mock the hook with the factory, not a hand-built object.
Call `createMockUseAnalyticsHook` again in `beforeEach` after
`jest.resetAllMocks()` — that wipes mock implementations. `jest.clearAllMocks()`
does not.

```ts
import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics';
import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock';

jest.mock('../../hooks/useAnalytics/useAnalytics');

beforeEach(() => {
jest.resetAllMocks();
jest.mocked(useAnalytics).mockReturnValue(
createMockUseAnalyticsHook({
trackEvent: mockTrackEvent,
}),
);
});
```

Standalone builder double (only when the test needs one):

```ts
createEventBuilder: jest.fn(() => createMockEventBuilder()),
```

## Reject

- `addSensitiveProperties` on new tracking. Existing call sites: drop those
fields only. Moving the last sensitive field into `addProperties` flips
`isAnonymous` (true iff `sensitiveProperties` is nonempty). Do not relocate
Comment thread
NicolasMassart marked this conversation as resolved.
without human sign-off.
- A new feature-local tracker that is not a file matching `*Tracking.ts` under
`app/util/analytics/`, a feature-local `abTestConfig.ts`, or a catalog
`generateOpt` module (`app/core/Analytics/MetaMetrics.events.ts`,
`app/core/Analytics/events/`, `<feature>/analytics/events.ts`)
- Replacing `MetaMetricsEvents.*` at a call site with `EVENT_NAME.*`
- Reintroducing `useMetrics` (removed) or MetaMetrics internals at call sites
- Dropping `generateOpt` `action` / `name` when migrating `IMetaMetricsEvent` call sites (until the catalog migration lands)
- Hand-built `useAnalytics` mock objects — use `createMockUseAnalyticsHook`
- Raw `initMessenger.call('AnalyticsController:trackEvent', …)` when Engine
`trackEvent` / `buildAndTrackEvent` is available
- Defaulting UI tests to `createEventBuilder: jest.fn(() => createMockEventBuilder())`
when `createMockUseAnalyticsHook()` already stubs the builder
- Attaching a new control to a catalog event whose live call sites are a different product (example: `VIEW_ALL_ASSETS_CLICKED` is wallet tokens/NFTs `asset_type`, not a homepage section)
- Firing an existing catalog event at a new lifecycle (example: `TOKEN_DETECTED` on controller init). Add a catalog name for that lifecycle.
25 changes: 25 additions & 0 deletions domains/platform/skills/analytics/skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
name: analytics
description: >-
Product analytics and event tracking. Use when adding, migrating, or
reviewing tracked events, or when writing tests for analytics call sites.
maturity: stable
base: true
---

# Analytics

Use this skill for product event tracking.

## When to use

- Adding or migrating event tracking in UI or non-UI code
- Writing or updating tests for analytics call sites
- Reviewing a PR that introduces or changes tracked events

## Workflow

1. Register this interaction in the catalog (`EVENT_NAME` + `generateOpt` in catalog modules). Reuse an existing catalog name only when this control is another instance of that same interaction (same dashboard event, same owners).
2. Attach properties on the event builder.
3. Send the built event through the tracking entry point.
4. In UI tests, wrap `useAnalytics` with the test factory (including files that already mock the hook). In non-React tests, assert the builder and the helper or Engine tracking util.