diff --git a/packages/browser/src/browser/__tests__/integration.test.ts b/packages/browser/src/browser/__tests__/integration.test.ts index 1d582f340..37b8e9ca0 100644 --- a/packages/browser/src/browser/__tests__/integration.test.ts +++ b/packages/browser/src/browser/__tests__/integration.test.ts @@ -17,6 +17,11 @@ import { } from '../../test-helpers/test-writekeys' import { PriorityQueue } from '../../lib/priority-queue' import { getCDN, setGlobalCDNUrl } from '../../lib/parse-cdn' +import { + clearAjsBrowserStorage, + getBrowserStorage, +} from '../../test-helpers/browser-storage' +import { after } from 'lodash' // eslint-disable-next-line @typescript-eslint/no-explicit-any let fetchCalls: Array[] = [] @@ -397,6 +402,30 @@ describe('Initialization', () => { }) }) +describe('AnononymousId behavior', () => { + beforeEach(() => { + clearAjsBrowserStorage() + }) + afterAll(() => { + clearAjsBrowserStorage() + }) + it('resets anonymousId if run synchronously', async () => { + const [analytics] = await AnalyticsBrowser.load({ writeKey }) + const getAnonId = () => + getBrowserStorage({ + cookieNames: ['ajs_anonymous_id'], + localStorageKeys: ['ajs_anonymous_id'], + }).ajs_anonymous_id + expect(getAnonId()).toBeFalsy() + const track = analytics.track('foo') + expect(typeof getAnonId()).toBe('string') + analytics.reset() + expect(getAnonId()).toBeFalsy() + await track + expect(getAnonId()).toBeFalsy() + }) +}) + describe('Dispatch', () => { it('dispatches events to destinations', async () => { const [ajs] = await AnalyticsBrowser.load({ diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/integration.test.ts index 9dae7b1dc..29147bb51 100644 --- a/packages/browser/src/core/analytics/__tests__/integration.test.ts +++ b/packages/browser/src/core/analytics/__tests__/integration.test.ts @@ -1,6 +1,9 @@ import { PriorityQueue } from '../../../lib/priority-queue' import { MiddlewareParams } from '../../../plugins/middleware' -import { retrieveStoredData } from '../../../test-helpers/retrieve-stored-data' +import { + getAjsBrowserStorage, + clearAjsBrowserStorage, +} from '../../../test-helpers/browser-storage' import { Context } from '../../context' import { Plugin } from '../../plugin' import { EventQueue } from '../../queue/event-queue' @@ -224,12 +227,13 @@ describe('Analytics', () => { }) describe('reset', () => { + beforeEach(() => { + clearAjsBrowserStorage() + }) + it('clears user and group data', async () => { const analytics = new Analytics({ writeKey: '' }) - const cookieNames = ['ajs_user_id', 'ajs_anonymous_id', 'ajs_group_id'] - const localStorageKeys = ['ajs_user_traits', 'ajs_group_properties'] - analytics.user().anonymousId('unknown-user') analytics.user().id('known-user') analytics.user().traits({ job: 'engineer' }) @@ -237,7 +241,8 @@ describe('Analytics', () => { analytics.group().traits({ team: 'analytics' }) // Ensure all cookies/localstorage is written correctly first - let storedData = retrieveStoredData({ cookieNames, localStorageKeys }) + + let storedData = getAjsBrowserStorage() expect(storedData).toEqual({ ajs_user_id: 'known-user', ajs_anonymous_id: 'unknown-user', @@ -252,7 +257,7 @@ describe('Analytics', () => { // Now make sure everything was cleared on reset analytics.reset() - storedData = retrieveStoredData({ cookieNames, localStorageKeys }) + storedData = getAjsBrowserStorage() expect(storedData).toEqual({}) }) }) diff --git a/packages/browser/src/core/events/__tests__/index.test.ts b/packages/browser/src/core/events/__tests__/index.test.ts index 5c6ecfe75..f8441d98b 100644 --- a/packages/browser/src/core/events/__tests__/index.test.ts +++ b/packages/browser/src/core/events/__tests__/index.test.ts @@ -322,6 +322,48 @@ describe('Event Factory', () => { innerProp: '👻', }) }) + + describe.skip('anonymousId', () => { + // TODO: the code should be fixed so that these tests can pass -- this eventFactory does not seem to handle these edge cases well. + // When an event is dispatched, there are four places anonymousId can live: event.anonymousId, event.options.anonymousId, event.context.anonymousId, and the user object / localStorage. + // It would be good to have a source of truth + test('accepts an anonymousId', () => { + const track = factory.track('Order Completed', shoes, { + anonymousId: 'foo', + }) + expect(track.anonymousId).toBe('foo') + expect(track.context?.anonymousId).toBe('foo') + }) + + test('custom passed anonymousId should set global user instance', () => { + const id = Math.random().toString() + factory.track('Order Completed', shoes, { + anonymousId: id, + }) + expect(user.anonymousId()).toBe(id) + }) + + test('if two different anonymousIds are passed, should use one on the event', () => { + const track = factory.track('Order Completed', shoes, { + anonymousId: 'bar', + context: { + anonymousId: 'foo', + }, + }) + expect(track.context?.anonymousId).toBe('bar') + expect(track.anonymousId).toBe('bar') + }) + + test('should set an anonymousId passed from the context on the event', () => { + const track = factory.track('Order Completed', shoes, { + context: { + anonymousId: 'foo', + }, + }) + expect(track.context?.anonymousId).toBe('foo') + expect(track.anonymousId).toBe('foo') + }) + }) }) describe('normalize', function () { diff --git a/packages/browser/src/core/events/index.ts b/packages/browser/src/core/events/index.ts index 4ec288664..9424f80d2 100644 --- a/packages/browser/src/core/events/index.ts +++ b/packages/browser/src/core/events/index.ts @@ -205,6 +205,15 @@ export class EventFactory { } public normalize(event: SegmentEvent): SegmentEvent { + const anonymousIdOverride = event.options?.anonymousId + if (anonymousIdOverride) { + // set anonymousId globally if we encounter an override + //segment.com/docs/connections/sources/catalog/libraries/website/javascript/identity/#override-the-anonymous-id-using-the-options-object + const id = this.user.anonymousId(anonymousIdOverride) + // sync the user to the property on the event itself + event.anonymousId = id + } + const integrationBooleans = Object.keys(event.integrations ?? {}).reduce( (integrationNames, name) => { return { diff --git a/packages/browser/src/plugins/segmentio/normalize.ts b/packages/browser/src/plugins/segmentio/normalize.ts index 3ba42dfef..93b9bfad9 100644 --- a/packages/browser/src/plugins/segmentio/normalize.ts +++ b/packages/browser/src/plugins/segmentio/normalize.ts @@ -124,7 +124,6 @@ export function normalize( json.context = json.context ?? json.options ?? {} const ctx = json.context - const anonId = json.anonymousId delete json.options json.writeKey = settings?.apiKey @@ -159,7 +158,8 @@ export function normalize( referrerId(query, ctx, analytics.options.disableClientPersistence ?? false) json.userId = json.userId || user.id() - json.anonymousId = user.anonymousId(anonId) + json.anonymousId = json.anonymousId || user.anonymousId() + json.sentAt = new Date() const failed = analytics.queue.failedInitializations || [] diff --git a/packages/browser/src/test-helpers/browser-storage.ts b/packages/browser/src/test-helpers/browser-storage.ts new file mode 100644 index 000000000..7e602785a --- /dev/null +++ b/packages/browser/src/test-helpers/browser-storage.ts @@ -0,0 +1,70 @@ +import cookie from 'js-cookie' + +const ajsCookieNames = [ + 'ajs_user_id', + 'ajs_anonymous_id', + 'ajs_group_id', +] as const +const ajsLocalStorageKeys = ['ajs_user_traits', 'ajs_group_properties'] as const + +export const getAjsBrowserStorage = () => { + return getBrowserStorage({ + cookieNames: ajsCookieNames, + localStorageKeys: ajsLocalStorageKeys, + }) +} + +export const clearAjsBrowserStorage = () => { + return clearBrowserStorage({ + cookieNames: ajsCookieNames, + localStorageKeys: ajsLocalStorageKeys, + }) +} + +export function getBrowserStorage< + CookieNames extends string, + LSKeys extends string +>({ + cookieNames, + localStorageKeys, +}: { + cookieNames: readonly CookieNames[] + localStorageKeys: readonly LSKeys[] +}): Record { + const result = {} as ReturnType + + const cookies = cookie.get() + cookieNames.forEach((name) => { + if (name in cookies) { + result[name] = cookies[name] + } + }) + + localStorageKeys.forEach((key) => { + const value = localStorage.getItem(key) + if (value !== null && typeof value !== 'undefined') { + result[key] = JSON.parse(value) + } + }) + + return result +} + +export function clearBrowserStorage({ + cookieNames, + localStorageKeys, // if no keys are passed, the entire thing is cleared +}: { + cookieNames: string[] | readonly string[] + localStorageKeys?: string[] | readonly string[] +}) { + cookieNames.forEach((name) => { + cookie.remove(name) + }) + if (!localStorageKeys) { + localStorage.clear() + } else { + localStorageKeys.forEach((key) => { + localStorage.removeItem(key) + }) + } +} diff --git a/packages/browser/src/test-helpers/retrieve-stored-data.ts b/packages/browser/src/test-helpers/retrieve-stored-data.ts deleted file mode 100644 index 91b5ce259..000000000 --- a/packages/browser/src/test-helpers/retrieve-stored-data.ts +++ /dev/null @@ -1,29 +0,0 @@ -import cookie from 'js-cookie' - -export interface RetrieveStoredDataProps { - cookieNames: string[] - localStorageKeys: string[] -} - -export function retrieveStoredData({ - cookieNames, - localStorageKeys, -}: RetrieveStoredDataProps): Record { - const result: ReturnType = {} - - const cookies = cookie.get() - cookieNames.forEach((name) => { - if (name in cookies) { - result[name] = cookies[name] - } - }) - - localStorageKeys.forEach((key) => { - const value = localStorage.getItem(key) - if (value !== null && typeof value !== 'undefined') { - result[key] = JSON.parse(value) - } - }) - - return result -}