Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 64 additions & 24 deletions packages/sdk-analytics/README.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,79 @@
# MetaMask SDK Analytics

This package provides a client for tracking analytics events for dApps and other components using the MetaMask SDK. It is designed to be fully type-safe, leveraging a schema generated from a backend OpenAPI specification.

## Overview

The `@metamask/sdk-analytics` package tracks analytics events for dApps using the MetaMask SDK. It provides a TypeScript-based client for sending events to an analytics API with batching and schema validation.
The analytics client is a singleton that exposes two distinct sub-clients for different API versions:

- `analytics.v1`: A legacy client for sending events to the V1 backend endpoint. Its interface is designed for backward compatibility.
- `analytics.v2`: A modern, namespaced client for sending events to the V2 backend endpoint. This is the recommended client for all new implementations.

## Purpose
A single `analytics.enable()` call controls event tracking for both clients.

Enables dApps to:
## Usage

- Track SDK events (e.g., sdk_initialized, connection_initiated).
- Send events to an analytics API.
- Ensure type safety with OpenAPI schemas.
### For New Implementations (V2)

## Features
The V2 client uses a "namespaced" approach. Each event belongs to a specific namespace, which must be configured before tracking.

- Event Tracking: Supports events like sdk_initialized, sdk_used_chain, and wallet actions.
- Batching: Events are batched for efficient network usage.
- Error Handling: Uses exponential backoff for failed requests.
- Type Safety: Leverages TypeScript and OpenAPI schemas.
1. **Import the client:**
```typescript
import { analytics } from '@metamask/sdk-analytics';
```

## Usage
2. **Enable tracking:**
This should be done once when your application starts.
```typescript
analytics.enable();
```

3. **Set up your namespace:**
Configure any common properties that should be included with every event for your namespace. For SDKs, this should include `package_name` and `package_version`.
```typescript
import { name as packageName, version as packageVersion } from '../package.json';

analytics.v2.setup('sdk/connect', {
package_name: packageName,
package_version: packageVersion,
platform: 'web-desktop',
});
```

4. **Track an event:**
The `track` method is fully type-safe. Your editor will provide autocomplete for the `namespace`, `eventName`, and the `properties` required for that event, all based on the master schema.
```typescript
analytics.v2.track('sdk/connect', 'sdk_initialized', {
dapp_id: 'aave.com',
anon_id: 'some-anonymous-uuid',
integration_type: 'direct',
});
```

### For Migrating Legacy V1 Code

Import the global client, enable it, set global props and track events.
The `v1` client provides an interface that is backward compatible with previous versions of this package, making migration straightforward.

```typescript
import { analytics } from '@metamask/sdk-analytics';
1. **Enable tracking:**
```typescript
import { analytics } from '@metamask/sdk-analytics';

analytics.enable();
analytics.enable();
```

analytics.setGlobalProperty('sdk_version', '1.0.0');
analytics.setGlobalProperty('platform', 'web-desktop');
2. **Set global properties:**
Use `setGlobalProperty` to set common properties for all V1 events.
```typescript
analytics.v1.setGlobalProperty('sdk_version', '0.9.0');
analytics.v1.setGlobalProperty('platform', 'web-desktop');
```

analytics.track('sdk_initialized', {
dapp_id: 'example.com',
anon_id: 'bbbc1727-8b85-433a-a26a-e9df70ddc81c',
integration_type: 'direct',
});
```
3. **Track an event:**
The `track` method on the `v1` client uses the original flat event structure.
```typescript
analytics.v1.track('sdk_initialized', {
dapp_id: 'example.com',
anon_id: 'some-anonymous-uuid',
integration_type: 'direct',
});
```
202 changes: 142 additions & 60 deletions packages/sdk-analytics/src/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,79 +5,161 @@
import Analytics from './analytics';
import type * as schema from './schema';

t.describe('Analytics Integration', () => {
type EventV1 = schema.components['schemas']['Event'];
type EventV2 = schema.components['schemas']['EventV2'];

const BASE_URL = 'http://localhost:8000';

t.describe('Analytics Client', () => {
let analytics: Analytics;
let scope: nock.Scope;

const event: schema.components['schemas']['SdkInitializedEvent'] = {
name: 'sdk_initialized',
sdk_version: '1.0.0',
dapp_id: 'aave.com',
anon_id: 'bbbc1727-8b85-433a-a26a-e9df70ddc81c',
platform: 'web-desktop',
integration_type: 'direct',
};
t.beforeEach(() => {
analytics = new Analytics(BASE_URL);
nock.cleanAll();

Check failure on line 18 in packages/sdk-analytics/src/analytics.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (18.x)

Caution: `nock` also has a named export `cleanAll`. Check if you meant to write `import {cleanAll} from 'nock'` instead
});

t.afterAll(() => {
/* eslint-disable-next-line import-x/no-named-as-default-member */
nock.cleanAll();

Check failure on line 22 in packages/sdk-analytics/src/analytics.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (18.x)

Caution: `nock` also has a named export `cleanAll`. Check if you meant to write `import {cleanAll} from 'nock'` instead
});

t.it('should do nothing when disabled', async () => {
let captured: Event[] = [];
scope = nock('http://127.0.0.1')
.post('/v1/events', (body) => {
captured = body; // Capture the request body directly
return true; // Accept any body to proceed with the intercept
})
.optionally()
.reply(
200,
{ status: 'success' },
{ 'Content-Type': 'application/json' },
);

analytics = new Analytics('http://127.0.0.1');
analytics.track(event.name, { ...event });

// Wait for the Sender to flush the event (baseIntervalMs = 200ms + buffer)
t.it('should do nothing if enable() is not called', async () => {
// Set up nock to fail the test if any request is made
const scope = nock(BASE_URL)
.post('/v1/events')
.reply(200)
.post('/v2/events')
.reply(200);

analytics.v1.track('sdk_initialized', {});
analytics.v2.setup('sdk/connect', {});
analytics.v2.track('sdk/connect', 'sdk_initialized', {
package_name: 'test',
package_version: '1.0.0',
dapp_id: 'test',
anon_id: 'test',
platform: 'web-desktop',
integration_type: 'test',
});

// Wait a bit to ensure no flush happens
await new Promise((resolve) => setTimeout(resolve, 300));

// Verify the captured payload
t.expect(captured).toEqual([]);
t.expect(scope.isDone()).toBe(false); // No requests should have been made
});

t.describe('analytics.v1', () => {
t.it(
'should send a correctly formatted V1 event to the /v1/events endpoint',
async () => {
let capturedBody: EventV1[] | undefined;
const scope = nock(BASE_URL)
.post('/v1/events', (body) => {
capturedBody = body;
return true;
})
.reply(200, { status: 'success' });

scope.done();
analytics.enable();
analytics.v1.setGlobalProperty('platform', 'web-desktop');
analytics.v1.track('sdk_initialized', {
sdk_version: '0.0.1',
dapp_id: 'test.com',
anon_id: 'anon-123',
integration_type: 'test',
});

// Wait for the sender to flush
await new Promise((resolve) => setTimeout(resolve, 300));

t.expect(scope.isDone()).toBe(true);
t.expect(capturedBody).toBeDefined();
t.expect(capturedBody).toHaveLength(1);
t.expect(capturedBody?.[0]).toEqual({
name: 'sdk_initialized',
platform: 'web-desktop', // Global property
sdk_version: '0.0.1',
dapp_id: 'test.com',
anon_id: 'anon-123',
integration_type: 'test',
});
},
);
});

t.it('should track an event when enabled', async () => {
let captured: Event[] = [];
scope = nock('http://127.0.0.2')
.post('/v1/events', (body) => {
captured = body; // Capture the request body directly
return true; // Accept any body to proceed with the intercept
})
.reply(
200,
{ status: 'success' },
{ 'Content-Type': 'application/json' },
);

analytics = new Analytics('http://127.0.0.2');
analytics.enable();
analytics.setGlobalProperty('sdk_version', event.sdk_version);
analytics.setGlobalProperty('anon_id', event.anon_id);
analytics.setGlobalProperty('platform', event.platform);
analytics.setGlobalProperty('integration_type', event.integration_type);
analytics.track(event.name, { dapp_id: 'some-non-global-property' });

// Wait for the Sender to flush the event (baseIntervalMs = 200ms + buffer)
await new Promise((resolve) => setTimeout(resolve, 300));
t.describe('analytics.v2', () => {
const sdkConnectProps = {
package_name: '@metamask/sdk-multichain',
package_version: '1.0.0',
dapp_id: 'aave.com',
anon_id: 'anon-456',
platform: 'web-desktop',
integration_type: 'direct',
} as const;

t.it(
'should send a correctly formatted V2 event to the /v2/events endpoint',
async () => {
let capturedBody: EventV2[] | undefined;
const scope = nock(BASE_URL)
.post('/v2/events', (body) => {
capturedBody = body;
return true;
})
.reply(200, { status: 'success' });

analytics.enable();
analytics.v2.setup('sdk/connect', {
package_name: sdkConnectProps.package_name,
package_version: sdkConnectProps.package_version,
platform: sdkConnectProps.platform,
});

analytics.v2.track('sdk/connect', 'sdk_initialized', {
dapp_id: sdkConnectProps.dapp_id,
anon_id: sdkConnectProps.anon_id,
integration_type: sdkConnectProps.integration_type,
});

// Wait for the sender to flush
await new Promise((resolve) => setTimeout(resolve, 300));

// Verify the captured payload
t.expect(captured).toEqual([
{ ...event, dapp_id: 'some-non-global-property' },
]);
t.expect(scope.isDone()).toBe(true);
t.expect(capturedBody).toBeDefined();
t.expect(capturedBody).toHaveLength(1);
t.expect(capturedBody?.[0]).toEqual({
namespace: 'sdk/connect',
event_name: 'sdk_initialized',
properties: {
// Merged from setup() and track()
package_name: sdkConnectProps.package_name,
package_version: sdkConnectProps.package_version,
dapp_id: sdkConnectProps.dapp_id,
anon_id: sdkConnectProps.anon_id,
platform: sdkConnectProps.platform,
integration_type: sdkConnectProps.integration_type,
},
});
},
);

scope.done();
t.it(
'should throw an error if track() is called for a namespace without setup',
() => {
analytics.enable();
// This should throw because 'mobile/sdk-connect-v2' was never set up
t.expect(() =>
analytics.v2.track(
'mobile/sdk-connect-v2',
'wallet_action_received',
{
anon_id: 'test',
platform: 'mobile',
},
),
).toThrow(
'No configuration found for namespace: "mobile/sdk-connect-v2"',
);
},
);
});
});
Loading
Loading