diff --git a/.changeset/browser-identity-batch-boundaries.md b/.changeset/browser-identity-batch-boundaries.md new file mode 100644 index 0000000..a4fcdbd --- /dev/null +++ b/.changeset/browser-identity-batch-boundaries.md @@ -0,0 +1,5 @@ +--- +"@outlit/browser": patch +--- + +Flush queued browser events before changing or clearing attribution identity so account switches cannot reattribute earlier events. diff --git a/packages/browser/__tests__/unit/tracker.test.ts b/packages/browser/__tests__/unit/tracker.test.ts index b55b0f7..33a7210 100644 --- a/packages/browser/__tests__/unit/tracker.test.ts +++ b/packages/browser/__tests__/unit/tracker.test.ts @@ -200,4 +200,105 @@ describe("payload identity", () => { expect(payload.userIdentity).not.toHaveProperty("customerId") expect(payload.customerIdentity).not.toHaveProperty("customerTraits") }) + + it("flushes queued events with the previous identity before an account switch", async () => { + const outlit = new Outlit({ + publicKey: "pk_test", + autoTrack: false, + trackPageviews: false, + trackForms: false, + trackEngagement: false, + }) + outlit.enableTracking() + outlit.identify({ email: "user-a@example.com", customerId: "customer_a" }) + await outlit.flush() + + outlit.track("user_a_event") + outlit.identify({ email: "user-b@example.com", customerId: "customer_b" }) + await outlit.flush() + + const payloads = vi + .mocked(global.fetch) + .mock.calls.map(([, options]) => JSON.parse(String(options?.body))) as Array<{ + userIdentity?: { email?: string } + customerIdentity?: { customerId?: string } + events: Array<{ eventName?: string; type: string }> + }> + + expect(payloads).toHaveLength(3) + expect(payloads[1]).toMatchObject({ + userIdentity: { email: "user-a@example.com" }, + customerIdentity: { customerId: "customer_a" }, + events: [{ eventName: "user_a_event", type: "custom" }], + }) + expect(payloads[2]).toMatchObject({ + userIdentity: { email: "user-b@example.com" }, + customerIdentity: { customerId: "customer_b" }, + events: [{ type: "identify" }], + }) + }) + + it("flushes queued events with the known identity before clearUser", async () => { + const outlit = new Outlit({ + publicKey: "pk_test", + autoTrack: false, + trackPageviews: false, + trackForms: false, + trackEngagement: false, + }) + outlit.enableTracking() + outlit.identify({ email: "user-a@example.com" }) + await outlit.flush() + + outlit.track("known_user_event") + outlit.clearUser() + outlit.track("anonymous_event") + await outlit.flush() + + const payloads = vi + .mocked(global.fetch) + .mock.calls.map(([, options]) => JSON.parse(String(options?.body))) as Array<{ + userIdentity?: { email?: string } + events: Array<{ eventName?: string; type: string }> + }> + + expect(payloads).toHaveLength(3) + expect(payloads[1]).toMatchObject({ + userIdentity: { email: "user-a@example.com" }, + events: [{ eventName: "known_user_event", type: "custom" }], + }) + expect(payloads[2]?.userIdentity).toBeUndefined() + expect(payloads[2]?.events).toEqual([ + expect.objectContaining({ eventName: "anonymous_event", type: "custom" }), + ]) + }) + + it("does not split a batch when attribution differs only by normalization", async () => { + const outlit = new Outlit({ + publicKey: "pk_test", + autoTrack: false, + trackPageviews: false, + trackForms: false, + trackEngagement: false, + }) + outlit.enableTracking() + outlit.identify({ email: "User-A@Example.com ", userId: " user_a " }) + await outlit.flush() + + outlit.track("same_identity_event") + outlit.identify({ email: " user-a@example.com", userId: "user_a" }) + await outlit.flush() + + const payloads = vi + .mocked(global.fetch) + .mock.calls.map(([, options]) => JSON.parse(String(options?.body))) as Array<{ + events: Array<{ eventName?: string; type: string }> + }> + + expect(payloads).toHaveLength(2) + expect(payloads[1]?.events).toEqual([ + expect.objectContaining({ eventName: "same_identity_event", type: "custom" }), + expect.objectContaining({ type: "identify" }), + ]) + }) }) diff --git a/packages/browser/src/tracker.ts b/packages/browser/src/tracker.ts index 1be1631..8f73560 100644 --- a/packages/browser/src/tracker.ts +++ b/packages/browser/src/tracker.ts @@ -286,15 +286,21 @@ export class Outlit { return } - if (options.email || options.userId) { - this.currentUser = { - email: options.email, - userId: options.userId, - customerId: options.customerId, - customerTraits: options.customerTraits, - traits: options.traits, - } + const nextUser = { + email: options.email, + userId: options.userId, + customerId: options.customerId, + customerTraits: options.customerTraits, + traits: options.traits, + } + + // sendEvents snapshots currentUser synchronously before its first await. Flush + // before changing attribution so queued events retain the identity they had + // when they were recorded. + if (this.hasAttributionChanged(nextUser)) { + void this.flush() } + this.currentUser = nextUser const event = buildIdentifyEvent({ url: window.location.href, @@ -337,6 +343,9 @@ export class Outlit { * Call this when the user logs out. */ clearUser(): void { + if (this.currentUser) { + void this.flush() + } this.currentUser = null this.pendingUser = null } @@ -345,7 +354,6 @@ export class Outlit { * Apply user identity and send identify event. */ private applyUser(identity: UserIdentity): void { - this.currentUser = identity this.identify({ email: identity.email, userId: identity.userId, @@ -355,6 +363,21 @@ export class Outlit { }) } + private hasAttributionChanged(nextUser: UserIdentity): boolean { + if (!this.currentUser) { + return false + } + + const normalizeEmail = (email: string | undefined) => email?.trim().toLowerCase() + const normalizeId = (id: string | undefined) => id?.trim() + + return ( + normalizeEmail(this.currentUser.email) !== normalizeEmail(nextUser.email) || + normalizeId(this.currentUser.userId) !== normalizeId(nextUser.userId) || + normalizeId(this.currentUser.customerId) !== normalizeId(nextUser.customerId) + ) + } + /** User namespace method for identity. */ readonly user: UserMethods = { identify: (options: BrowserIdentifyOptions) => this.identify(options),