fix: merge hashed auth user data into GTM auth events

This commit is contained in:
Benjamin Lu
2026-03-31 05:22:18 -07:00
parent 161522b138
commit 9454c9fdb4
5 changed files with 198 additions and 51 deletions

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createHash } from 'node:crypto'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
@@ -12,14 +13,32 @@ function lastDataLayerEntry(): Record<string, unknown> | undefined {
return dl?.[dl.length - 1] as Record<string, unknown> | undefined
}
async function flushAsyncWork() {
await Promise.resolve()
await Promise.resolve()
}
function toUint8Array(data: BufferSource): Uint8Array {
if (data instanceof ArrayBuffer) {
return new Uint8Array(data)
}
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
}
describe('GtmTelemetryProvider', () => {
beforeEach(() => {
vi.unstubAllGlobals()
window.__CONFIG__ = {}
window.dataLayer = undefined
window.gtag = undefined
document.head.innerHTML = ''
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('injects the GTM runtime script', () => {
window.__CONFIG__ = {
gtm_container_id: 'GTM-TEST123'
@@ -230,8 +249,20 @@ describe('GtmTelemetryProvider', () => {
})
})
it('pushes normalized email as user_data before auth event', () => {
it('pushes hashed email inside the auth event payload', async () => {
const provider = createInitializedProvider()
vi.stubGlobal('crypto', {
subtle: {
digest: vi.fn(
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
const digest = createHash('sha256')
.update(toUint8Array(data))
.digest()
return Uint8Array.from(digest).buffer
}
)
}
})
provider.trackAuth({
method: 'email',
@@ -240,21 +271,25 @@ describe('GtmTelemetryProvider', () => {
email: ' Test@Example.com '
})
const dl = window.dataLayer as Record<string, unknown>[]
const userData = dl.find((entry) => 'user_data' in entry)
expect(userData).toMatchObject({
user_data: { email: 'test@example.com' }
})
await flushAsyncWork()
// Verify user_data is pushed before the sign_up event
const userDataIndex = dl.findIndex((entry) => 'user_data' in entry)
const signUpIndex = dl.findIndex(
(entry) => (entry as Record<string, unknown>).event === 'sign_up'
)
expect(userDataIndex).toBeLessThan(signUpIndex)
const dl = window.dataLayer as Record<string, unknown>[]
const authEvent = dl.find((entry) => entry.event === 'sign_up')
expect(authEvent).toMatchObject({
event: 'sign_up',
method: 'email',
user_id: 'uid-123',
user_data: {
email: createHash('sha256').update('test@example.com').digest('hex')
}
})
expect(
dl.some((entry) => 'user_data' in entry && !('event' in entry))
).toBe(false)
expect(JSON.stringify(dl)).not.toContain('test@example.com')
})
it('does not push user_data when email is absent', () => {
it('omits user_data when email is absent', async () => {
const provider = createInitializedProvider()
provider.trackAuth({
@@ -263,9 +298,35 @@ describe('GtmTelemetryProvider', () => {
user_id: 'uid-456'
})
const dl = window.dataLayer as Record<string, unknown>[]
const userData = dl.find((entry) => 'user_data' in entry)
expect(userData).toBeUndefined()
await flushAsyncWork()
expect(lastDataLayerEntry()).toMatchObject({
event: 'login',
method: 'google',
user_id: 'uid-456'
})
expect(lastDataLayerEntry()).not.toHaveProperty('user_data')
})
it('omits user_data when hashing is unavailable', async () => {
const provider = createInitializedProvider()
vi.stubGlobal('crypto', undefined)
provider.trackAuth({
method: 'github',
is_new_user: false,
user_id: 'uid-789',
email: 'user@example.com'
})
await flushAsyncWork()
expect(lastDataLayerEntry()).toMatchObject({
event: 'login',
method: 'github',
user_id: 'uid-789'
})
expect(lastDataLayerEntry()).not.toHaveProperty('user_data')
})
it('does not push events when not initialized', () => {

View File

@@ -28,6 +28,7 @@ import type {
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { hashEmail } from '../../utils/hashEmail'
/**
* Google Tag Manager telemetry provider.
@@ -135,23 +136,25 @@ export class GtmTelemetryProvider implements TelemetryProvider {
}
trackAuth(metadata: AuthMetadata): void {
if (!this.initialized) return
void this.pushAuthEvent(metadata)
}
private async pushAuthEvent(metadata: AuthMetadata): Promise<void> {
const basePayload = {
method: metadata.method,
...(metadata.user_id ? { user_id: metadata.user_id } : {})
}
if (metadata.email) {
window.dataLayer?.push({
user_data: { email: metadata.email.trim().toLowerCase() }
})
}
const hashedEmail = await hashEmail(metadata.email, 'SHA-256')
const payload = hashedEmail
? {
...basePayload,
user_data: { email: hashedEmail }
}
: basePayload
if (metadata.is_new_user) {
this.pushEvent('sign_up', basePayload)
return
}
this.pushEvent('login', basePayload)
this.pushEvent(metadata.is_new_user ? 'sign_up' : 'login', payload)
}
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {

View File

@@ -1,4 +1,5 @@
import { captureCheckoutAttributionFromSearch } from '@/platform/telemetry/utils/checkoutAttribution'
import { hashEmail } from '@/platform/telemetry/utils/hashEmail'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useAuthStore } from '@/stores/authStore'
@@ -85,11 +86,9 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
if (typeof window === 'undefined') return
const { customerId, customerEmail } = this.resolveCustomerIdentity()
const normalizedEmail = customerEmail.trim().toLowerCase()
// Impact's Identify spec requires customerEmail to be sent as a SHA1 hash.
const hashedEmail = normalizedEmail
? await this.hashSha1(normalizedEmail)
: EMPTY_CUSTOMER_VALUE
const hashedEmail =
(await hashEmail(customerEmail, 'SHA-1')) ?? EMPTY_CUSTOMER_VALUE
window.ire?.('identify', {
customerId,
@@ -151,23 +150,4 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
return null
}
}
private async hashSha1(value: string): Promise<string> {
try {
if (!globalThis.crypto?.subtle || typeof TextEncoder === 'undefined') {
return EMPTY_CUSTOMER_VALUE
}
const digestBuffer = await crypto.subtle.digest(
'SHA-1',
new TextEncoder().encode(value)
)
return Array.from(new Uint8Array(digestBuffer))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
} catch {
return EMPTY_CUSTOMER_VALUE
}
}
}

View File

@@ -0,0 +1,70 @@
import { createHash } from 'node:crypto'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { hashEmail, normalizeEmail } from './hashEmail'
function toUint8Array(data: BufferSource): Uint8Array {
if (data instanceof ArrayBuffer) {
return new Uint8Array(data)
}
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
}
describe('hashEmail', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('normalizes email addresses before hashing', () => {
expect(normalizeEmail(' Test@Example.com ')).toBe('test@example.com')
expect(normalizeEmail(' ')).toBeUndefined()
expect(normalizeEmail(undefined)).toBeUndefined()
})
it('hashes normalized email with SHA-256', async () => {
vi.stubGlobal('crypto', {
subtle: {
digest: vi.fn(
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
const digest = createHash('sha256')
.update(toUint8Array(data))
.digest()
return Uint8Array.from(digest).buffer
}
)
}
})
await expect(hashEmail(' Test@Example.com ', 'SHA-256')).resolves.toBe(
createHash('sha256').update('test@example.com').digest('hex')
)
})
it('hashes normalized email with SHA-1', async () => {
vi.stubGlobal('crypto', {
subtle: {
digest: vi.fn(
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
const digest = createHash('sha1')
.update(toUint8Array(data))
.digest()
return Uint8Array.from(digest).buffer
}
)
}
})
await expect(hashEmail(' Test@Example.com ', 'SHA-1')).resolves.toBe(
createHash('sha1').update('test@example.com').digest('hex')
)
})
it('returns undefined when hashing is unavailable', async () => {
vi.stubGlobal('crypto', undefined)
await expect(hashEmail('test@example.com', 'SHA-256')).resolves.toBe(
undefined
)
})
})

View File

@@ -0,0 +1,33 @@
export type EmailHashAlgorithm = 'SHA-1' | 'SHA-256'
export function normalizeEmail(email?: string): string | undefined {
const normalizedEmail = email?.trim().toLowerCase()
return normalizedEmail || undefined
}
export async function hashEmail(
email: string | undefined,
algorithm: EmailHashAlgorithm
): Promise<string | undefined> {
const normalizedEmail = normalizeEmail(email)
if (
!normalizedEmail ||
!globalThis.crypto?.subtle ||
typeof TextEncoder === 'undefined'
) {
return undefined
}
try {
const digestBuffer = await globalThis.crypto.subtle.digest(
algorithm,
new TextEncoder().encode(normalizedEmail)
)
return Array.from(new Uint8Array(digestBuffer))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
} catch {
return undefined
}
}