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
5 changes: 5 additions & 0 deletions .changeset/browser-identity-batch-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@outlit/browser": patch
---

Flush queued browser events before changing or clearing attribution identity so account switches cannot reattribute earlier events.
101 changes: 101 additions & 0 deletions packages/browser/__tests__/unit/tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
])
})
})
41 changes: 32 additions & 9 deletions packages/browser/src/tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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,
Expand All @@ -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),
Expand Down
Loading