diff --git a/README.md b/README.md index 9e4c39c..d09da30 100644 --- a/README.md +++ b/README.md @@ -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`: 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) @@ -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. diff --git a/src/analytics/MetaRouterAnalyticsClient.test.ts b/src/analytics/MetaRouterAnalyticsClient.test.ts index 472434d..509a12e 100644 --- a/src/analytics/MetaRouterAnalyticsClient.test.ts +++ b/src/analytics/MetaRouterAnalyticsClient.test.ts @@ -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'); diff --git a/src/analytics/MetaRouterAnalyticsClient.ts b/src/analytics/MetaRouterAnalyticsClient.ts index 066451d..b7aae12 100644 --- a/src/analytics/MetaRouterAnalyticsClient.ts +++ b/src/analytics/MetaRouterAnalyticsClient.ts @@ -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 { + 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 */ diff --git a/src/analytics/init.test.ts b/src/analytics/init.test.ts index 1df6ba2..3ecdc90 100644 --- a/src/analytics/init.test.ts +++ b/src/analytics/init.test.ts @@ -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 () => { diff --git a/src/analytics/init.ts b/src/analytics/init.ts index 27239b4..0edeb56 100644 --- a/src/analytics/init.ts +++ b/src/analytics/init.ts @@ -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 | null = null; @@ -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) { @@ -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(); diff --git a/src/analytics/proxy/proxyClient.test.ts b/src/analytics/proxy/proxyClient.test.ts index 17cc018..983e67d 100644 --- a/src/analytics/proxy/proxyClient.test.ts +++ b/src/analytics/proxy/proxyClient.test.ts @@ -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 @@ -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); @@ -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' }); @@ -248,5 +273,6 @@ describe('proxyClient', () => { expect(mockClient.flush).toHaveBeenCalled(); expect(mockClient.reset).toHaveBeenCalled(); expect(mockClient.getDebugInfo).toHaveBeenCalled(); + expect(mockClient.getAnonymousId).toHaveBeenCalled(); }); }); diff --git a/src/analytics/proxy/proxyClient.ts b/src/analytics/proxy/proxyClient.ts index 9aab166..bde0b88 100644 --- a/src/analytics/proxy/proxyClient.ts +++ b/src/analytics/proxy/proxyClient.ts @@ -1,4 +1,4 @@ -import type { AnalyticsInterface } from "../types"; +import type { AnalyticsInterface } from '../types'; type PendingItem = { fn: () => void | Promise; @@ -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 = { flush: true, getDebugInfo: true, + getAnonymousId: true, setAdvertisingId: true, clearAdvertisingId: true, }; @@ -53,7 +59,7 @@ function handleMethodCall( ): ReturnType { // Real client bound if (realClient) { - if (methodName === "flush") { + if (methodName === 'flush') { if (flushInFlight) return flushInFlight as ReturnType; const p = Promise.resolve((realClient.flush as any)(...args)).then( @@ -81,7 +87,7 @@ function handleMethodCall( } // Special-cases while unbound: - if (methodName === "reset") { + if (methodName === 'reset') { // Nothing to reset pre-bind; resolve immediately return Promise.resolve() as ReturnType; } @@ -95,11 +101,11 @@ function handleMethodCall( 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) @@ -132,20 +138,23 @@ function handleMethodCall( } 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, - clearAdvertisingId: () => handleMethodCall("clearAdvertisingId") as Promise, - 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, + clearAdvertisingId: () => + handleMethodCall('clearAdvertisingId') as Promise, + setTracing: (enabled) => handleMethodCall('setTracing', enabled), + + flush: () => handleMethodCall('flush'), + reset: () => handleMethodCall('reset'), + enableDebugLogging: () => handleMethodCall('enableDebugLogging'), + getDebugInfo: () => handleMethodCall('getDebugInfo'), + getAnonymousId: () => handleMethodCall('getAnonymousId'), }; /** @@ -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 diff --git a/src/analytics/types.ts b/src/analytics/types.ts index dd49392..87f7687 100644 --- a/src/analytics/types.ts +++ b/src/analytics/types.ts @@ -51,6 +51,7 @@ export interface AnalyticsInterface { reset: () => Promise; enableDebugLogging: () => void; getDebugInfo: () => Promise>; + getAnonymousId: () => Promise; } export interface EventContext {