Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ The analytics client provides the following methods:
- `alias(newUserId: string)`: Connect anonymous users to known user IDs. See [Using the alias() Method](#using-the-alias-method) for details
- `setAdvertisingId(advertisingId: string)`: Set the advertising identifier (IDFA on iOS, GAID on Android) for ad tracking. See [Advertising ID](#advertising-id-idfagaid) section for usage and compliance requirements
- `clearAdvertisingId()`: Clear the advertising identifier from storage and context. Useful for GDPR/CCPA compliance when users opt out of ad tracking
- `getAnonymousId(): Promise<string>`: Returns the current anonymous ID. Async, never returns null — guaranteed to resolve a string after `init()`
- `setTracing(enabled: boolean)`: Enable or disable tracing headers on API requests. When enabled, includes a `Trace: true` header for debugging request flows
- `flush()`: Flush events immediately
- `reset()`: Reset analytics state and clear all stored data (includes clearing advertising ID)
Expand Down Expand Up @@ -393,6 +394,13 @@ The `anonymousId` is a unique identifier automatically generated for each device
- Remains stable across app sessions until `reset()` is called
- Cleared on `reset()` and a **new** UUID is generated on next `init()`

**Accessing the anonymous ID:**

```js
const anonymousId = await analytics.getAnonymousId();
console.log(anonymousId); // e.g. "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
```

**Use case:**
Track user behavior before they log in or create an account, then connect pre-login and post-login activity using the `alias()` method.

Expand Down
49 changes: 49 additions & 0 deletions src/analytics/MetaRouterAnalyticsClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,55 @@ describe('MetaRouterAnalyticsClient', () => {
expect(client.queue[0].context.device.advertisingId).toBeUndefined();
});

describe('getAnonymousId', () => {
it('returns the JS IdentityManager anonymous ID', async () => {
const client = new MetaRouterAnalyticsClient(opts);
await client.init();

const result = await client.getAnonymousId();
expect(result).toBe('anon-123');
});

it('is async and returns a string (never null)', async () => {
const client = new MetaRouterAnalyticsClient(opts);
await client.init();

const promise = client.getAnonymousId();
expect(promise).toBeInstanceOf(Promise);

const result = await promise;
expect(typeof result).toBe('string');
expect(result).toBeTruthy();
});

it('awaits init() when called while initialization is in-flight', async () => {
const client = new MetaRouterAnalyticsClient(opts);
const initPromise = client.init();
const idPromise = client.getAnonymousId();

await initPromise;
const result = await idPromise;
expect(result).toBe('anon-123');
});

it('throws when called before init() has ever been called', async () => {
const client = new MetaRouterAnalyticsClient(opts);
await expect(client.getAnonymousId()).rejects.toThrow(
/no anonymous ID available/
);
});

it('throws after reset() clears identity', async () => {
const client = new MetaRouterAnalyticsClient(opts);
await client.init();
await client.reset();

await expect(client.getAnonymousId()).rejects.toThrow(
/no anonymous ID available/
);
});
});

describe('network awareness', () => {
it('getDebugInfo includes networkStatus', async () => {
const monitor = new StubNetworkMonitor('connected');
Expand Down
18 changes: 18 additions & 0 deletions src/analytics/MetaRouterAnalyticsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,24 @@ export class MetaRouterAnalyticsClient {
log('Debug logging enabled');
}

/**
* Returns the current anonymous ID managed by the JS identity layer.
* Awaits init() if it is still in-flight. Throws only if the client was
* never initialized or a reset() races with this call.
*/
async getAnonymousId(): Promise<string> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be returning: Promise<string|null>? I took a peek at IdentityManager.getAnonymousId and it's return type says null is possible.

  /**
   * Retrieves the current anonymous ID.
   * @returns The anonymous ID or null if not initialized.
   */
  getAnonymousId(): string | null {
    return this.anonymousId;
  }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed to be in line with other SDKs and awaiting the string. So it's always going to be a string.

if (this.initPromise) {
await this.initPromise;
}
const id = this.identityManager.getAnonymousId();
if (!id) {
throw new Error(
'getAnonymousId: no anonymous ID available (client was reset or never initialized)'
);
}
return id;
}

/**
* Get current state for debugging
*/
Expand Down
1 change: 1 addition & 0 deletions src/analytics/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe('createAnalyticsClient', () => {
expect(typeof client.reset).toBe('function');
expect(typeof client.enableDebugLogging).toBe('function');
expect(typeof client.getDebugInfo).toBe('function');
expect(typeof client.getAnonymousId).toBe('function');
});

it('returns the same proxy (rebound under the hood)', async () => {
Expand Down
13 changes: 8 additions & 5 deletions src/analytics/init.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MetaRouterAnalyticsClient } from "./MetaRouterAnalyticsClient";
import { proxyClient, setRealClient } from "./proxy/proxyClient";
import type { InitOptions, AnalyticsInterface } from "./types";
import { MetaRouterAnalyticsClient } from './MetaRouterAnalyticsClient';
import { proxyClient, setRealClient } from './proxy/proxyClient';
import type { InitOptions, AnalyticsInterface } from './types';

// Only one initialization in flight
let initPromise: Promise<void> | null = null;
Expand All @@ -10,7 +10,8 @@ export function createAnalyticsClient(
options: InitOptions
): AnalyticsInterface {
// Check if options have changed - if so, force reset first
const optionsChanged = currentOptions &&
const optionsChanged =
currentOptions &&
JSON.stringify(currentOptions) !== JSON.stringify(options);

if (optionsChanged && initPromise) {
Expand All @@ -31,11 +32,13 @@ export function createAnalyticsClient(
screen: (name, props) => instance.screen(name, props),
page: (name, props) => instance.page(name, props),
alias: (newUserId) => instance.alias(newUserId),
setAdvertisingId: (advertisingId) => instance.setAdvertisingId(advertisingId),
setAdvertisingId: (advertisingId) =>
instance.setAdvertisingId(advertisingId),
clearAdvertisingId: () => instance.clearAdvertisingId(),
setTracing: (enabled) => instance.setTracing(enabled),
enableDebugLogging: () => instance.enableDebugLogging(),
getDebugInfo: () => instance.getDebugInfo(),
getAnonymousId: () => instance.getAnonymousId(),
flush: () => instance.flush(),
reset: async () => {
await instance.reset();
Expand Down
26 changes: 26 additions & 0 deletions src/analytics/proxy/proxyClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('proxyClient', () => {
reset: jest.fn().mockResolvedValue(undefined),
enableDebugLogging: jest.fn(),
getDebugInfo: jest.fn().mockResolvedValue({ ok: true }),
getAnonymousId: jest.fn().mockResolvedValue('anon-mock-id'),
};
const { setRealClient } = require('./proxyClient');
setRealClient(null, { dropPending: true }); // clear state between tests
Expand Down Expand Up @@ -204,6 +205,29 @@ describe('proxyClient', () => {
expect(mockClient.track).toHaveBeenCalledWith('after', undefined);
});

it('getAnonymousId queues pre-bind and resolves after real client is bound', async () => {
const { proxyClient, setRealClient } = require('./proxyClient');

const preBindPromise = proxyClient.getAnonymousId();

mockClient.getAnonymousId.mockResolvedValue('anon-789');
setRealClient(mockClient);

const result = await preBindPromise;
expect(result).toBe('anon-789');
expect(mockClient.getAnonymousId).toHaveBeenCalled();
});

it('getAnonymousId forwards immediately post-bind', async () => {
const { proxyClient, setRealClient } = require('./proxyClient');
mockClient.getAnonymousId.mockResolvedValue('post-bind-id');
setRealClient(mockClient);

const result = await proxyClient.getAnonymousId();
expect(result).toBe('post-bind-id');
expect(mockClient.getAnonymousId).toHaveBeenCalled();
});

it('allows new flush after unbind resets singleflight', async () => {
const { proxyClient, setRealClient } = require('./proxyClient');
setRealClient(mockClient);
Expand Down Expand Up @@ -237,6 +261,7 @@ describe('proxyClient', () => {
await proxyClient.flush();
await proxyClient.reset();
await proxyClient.getDebugInfo();
await proxyClient.getAnonymousId();

expect(mockClient.track).toHaveBeenCalledWith('e', { p: 1 });
expect(mockClient.identify).toHaveBeenCalledWith('u', { plan: 'pro' });
Expand All @@ -248,5 +273,6 @@ describe('proxyClient', () => {
expect(mockClient.flush).toHaveBeenCalled();
expect(mockClient.reset).toHaveBeenCalled();
expect(mockClient.getDebugInfo).toHaveBeenCalled();
expect(mockClient.getAnonymousId).toHaveBeenCalled();
});
});
53 changes: 31 additions & 22 deletions src/analytics/proxy/proxyClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AnalyticsInterface } from "../types";
import type { AnalyticsInterface } from '../types';

type PendingItem = {
fn: () => void | Promise<void>;
Expand All @@ -11,10 +11,16 @@ const MAX_PENDING_CALLS = 20;
let realClient: AnalyticsInterface | null = null;

// Async-returning methods
type AsyncMethod = "flush" | "getDebugInfo" | "setAdvertisingId" | "clearAdvertisingId";
type AsyncMethod =
| 'flush'
| 'getDebugInfo'
| 'getAnonymousId'
| 'setAdvertisingId'
| 'clearAdvertisingId';
const ASYNC_METHODS: Record<AsyncMethod, true> = {
flush: true,
getDebugInfo: true,
getAnonymousId: true,
setAdvertisingId: true,
clearAdvertisingId: true,
};
Expand Down Expand Up @@ -53,7 +59,7 @@ function handleMethodCall<T extends keyof AnalyticsInterface>(
): ReturnType<AnalyticsInterface[T]> {
// Real client bound
if (realClient) {
if (methodName === "flush") {
if (methodName === 'flush') {
if (flushInFlight)
return flushInFlight as ReturnType<AnalyticsInterface[T]>;
const p = Promise.resolve((realClient.flush as any)(...args)).then(
Expand Down Expand Up @@ -81,7 +87,7 @@ function handleMethodCall<T extends keyof AnalyticsInterface>(
}

// Special-cases while unbound:
if (methodName === "reset") {
if (methodName === 'reset') {
// Nothing to reset pre-bind; resolve immediately
return Promise.resolve() as ReturnType<AnalyticsInterface[T]>;
}
Expand All @@ -95,11 +101,11 @@ function handleMethodCall<T extends keyof AnalyticsInterface>(
try {
if (!realClient) {
return reject(
new Error("Proxy detached before real client was bound")
new Error('Proxy detached before real client was bound')
);
}

if (methodName === "flush") {
if (methodName === 'flush') {
if (!flushInFlight) {
const p = Promise.resolve(
(realClient.flush as any)(...args)
Expand Down Expand Up @@ -132,20 +138,23 @@ function handleMethodCall<T extends keyof AnalyticsInterface>(
}

export const proxyClient: AnalyticsInterface = {
track: (event, props) => handleMethodCall("track", event, props),
identify: (userId, traits) => handleMethodCall("identify", userId, traits),
group: (groupId, traits) => handleMethodCall("group", groupId, traits),
screen: (name, props) => handleMethodCall("screen", name, props),
page: (name, props) => handleMethodCall("page", name, props),
alias: (newUserId) => handleMethodCall("alias", newUserId),
setAdvertisingId: (advertisingId) => handleMethodCall("setAdvertisingId", advertisingId) as Promise<void>,
clearAdvertisingId: () => handleMethodCall("clearAdvertisingId") as Promise<void>,
setTracing: (enabled) => handleMethodCall("setTracing", enabled),

flush: () => handleMethodCall("flush"),
reset: () => handleMethodCall("reset"),
enableDebugLogging: () => handleMethodCall("enableDebugLogging"),
getDebugInfo: () => handleMethodCall("getDebugInfo"),
track: (event, props) => handleMethodCall('track', event, props),
identify: (userId, traits) => handleMethodCall('identify', userId, traits),
group: (groupId, traits) => handleMethodCall('group', groupId, traits),
screen: (name, props) => handleMethodCall('screen', name, props),
page: (name, props) => handleMethodCall('page', name, props),
alias: (newUserId) => handleMethodCall('alias', newUserId),
setAdvertisingId: (advertisingId) =>
handleMethodCall('setAdvertisingId', advertisingId) as Promise<void>,
clearAdvertisingId: () =>
handleMethodCall('clearAdvertisingId') as Promise<void>,
setTracing: (enabled) => handleMethodCall('setTracing', enabled),

flush: () => handleMethodCall('flush'),
reset: () => handleMethodCall('reset'),
enableDebugLogging: () => handleMethodCall('enableDebugLogging'),
getDebugInfo: () => handleMethodCall('getDebugInfo'),
getAnonymousId: () => handleMethodCall('getAnonymousId'),
};

/**
Expand Down Expand Up @@ -187,13 +196,13 @@ export function setRealClient(
try {
Promise.resolve(fn()).catch(() => {});
} catch (err) {
console.warn("[MetaRouter] replay error:", err);
console.warn('[MetaRouter] replay error:', err);
}
}
} else {
if (opts?.dropPending) {
for (const it of pendingCalls)
it.reject?.(new Error("[MetaRouter] Proxy dropped before bind"));
it.reject?.(new Error('[MetaRouter] Proxy dropped before bind'));
pendingCalls.length = 0;
}
flushInFlight = null; // reset coalescer
Expand Down
1 change: 1 addition & 0 deletions src/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface AnalyticsInterface {
reset: () => Promise<void>;
enableDebugLogging: () => void;
getDebugInfo: () => Promise<Record<string, any>>;
getAnonymousId: () => Promise<string>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, should this be Promise<string|null> ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

}

export interface EventContext {
Expand Down
Loading