Compare commits

...

1 Commits

Author SHA1 Message Date
Nav Singh
2177b67d2f feat: identify users to Syft on login for enrichment coverage
Adds a SyftTelemetryProvider (cloud builds only) that loads the Syft
snippet and identifies users by email at login/signup via the existing
trackAuth dispatch, plus on session restore. Source is 'signup' for new
users and 'login' otherwise. Also loads the snippet on the marketing
site for anonymous visitor observation. Loader is idempotent with the
existing GTM-managed Syft tag.

OSS builds are unaffected: the provider is only reachable through
initTelemetry's cloud gate (verified: no syft/sy-d.io references in an
OSS dist build).
2026-06-05 13:30:56 -07:00
8 changed files with 423 additions and 2 deletions

View File

@@ -1 +1,21 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_SYFT_SOURCE_ID?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
interface Window {
syft?: {
identify: (...args: unknown[]) => void
signup: (...args: unknown[]) => void
track: (...args: unknown[]) => void
page: (...args: unknown[]) => void
q?: unknown[][]
fi?: unknown[]
}
syftc?: { sourceId: string }
}

View File

@@ -149,11 +149,13 @@ const websiteJsonLd = {
import { initSmoothScroll, cancelScroll } from '../scripts/smoothScroll'
import { ScrollTrigger } from '../scripts/gsapSetup'
import { initPostHog, capturePageview } from '../scripts/posthog'
import { initSyft } from '../scripts/syft'
initSmoothScroll()
if (import.meta.env.PROD) {
initPostHog()
initSyft()
document.addEventListener('astro:page-load', capturePageview)
}

View File

@@ -0,0 +1,41 @@
const SYFT_SOURCE_ID =
import.meta.env.PUBLIC_SYFT_SOURCE_ID ?? 'cmo1xgq4o000804jr5jlj5dtn'
const SYFT_SRC = 'https://cdn.sy-d.io/syftnext/syft.umd.js'
let initialized = false
/**
* Loads the SyftData snippet for anonymous visitor observation.
* Idempotent with the Syft tag in GTM: no-ops if `window.syft` exists.
*/
export function initSyft() {
if (initialized || typeof window === 'undefined' || !SYFT_SOURCE_ID) return
try {
window.syftc = { sourceId: SYFT_SOURCE_ID }
if (window.syft) {
initialized = true
return
}
const q: unknown[][] = []
const enqueue =
(method: string) =>
(...args: unknown[]) => {
q.push([method, ...args])
}
window.syft = {
q,
fi: [],
identify: enqueue('identify'),
signup: enqueue('signup'),
track: enqueue('track'),
page: enqueue('page')
}
const script = document.createElement('script')
script.src = SYFT_SRC
script.async = true
document.head.appendChild(script)
initialized = true
} catch (error) {
console.error('Syft init failed', error)
}
}

1
global.d.ts vendored
View File

@@ -49,6 +49,7 @@ interface Window {
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Record<string, unknown>
syftdata_source_id?: string
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -82,6 +82,7 @@ export type RemoteConfig = {
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Partial<PostHogConfig>
syftdata_source_id?: string
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
max_upload_size?: number

View File

@@ -26,14 +26,16 @@ export async function initTelemetry(): Promise<void> {
{ GtmTelemetryProvider },
{ ImpactTelemetryProvider },
{ PostHogTelemetryProvider },
{ ClickHouseTelemetryProvider }
{ ClickHouseTelemetryProvider },
{ SyftTelemetryProvider }
] = await Promise.all([
import('./TelemetryRegistry'),
import('./providers/cloud/MixpanelTelemetryProvider'),
import('./providers/cloud/GtmTelemetryProvider'),
import('./providers/cloud/ImpactTelemetryProvider'),
import('./providers/cloud/PostHogTelemetryProvider'),
import('./providers/cloud/ClickHouseTelemetryProvider')
import('./providers/cloud/ClickHouseTelemetryProvider'),
import('./providers/cloud/SyftTelemetryProvider')
])
const registry = new TelemetryRegistry()
@@ -42,6 +44,7 @@ export async function initTelemetry(): Promise<void> {
registry.registerProvider(new ImpactTelemetryProvider())
registry.registerProvider(new PostHogTelemetryProvider())
registry.registerProvider(new ClickHouseTelemetryProvider())
registry.registerProvider(new SyftTelemetryProvider())
setTelemetryRegistry(registry)
})()

View File

@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockUserState = vi.hoisted(() => ({
onUserResolved: vi.fn(),
userEmail: { value: undefined as string | undefined }
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => mockUserState
}))
const SYFT_SRC = 'https://cdn.sy-d.io/syftnext/syft.umd.js'
type ConfigWindow = { __CONFIG__?: { syftdata_source_id?: string } }
const importProvider = async () => {
const { SyftTelemetryProvider } =
await import('@/platform/telemetry/providers/cloud/SyftTelemetryProvider')
return SyftTelemetryProvider
}
const querySyftScripts = () =>
document.querySelectorAll(`script[src="${SYFT_SRC}"]`)
describe('SyftTelemetryProvider', () => {
let warn: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
querySyftScripts().forEach((el) => el.remove())
delete window.syft
delete window.syftc
delete (window as ConfigWindow).__CONFIG__
mockUserState.userEmail.value = undefined
warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
})
afterEach(() => {
warn.mockRestore()
})
describe('initialization', () => {
it('warns and skips snippet load when no source id is configured', async () => {
const SyftTelemetryProvider = await importProvider()
new SyftTelemetryProvider()
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Syft source id')
)
expect(window.syft).toBeUndefined()
expect(querySyftScripts()).toHaveLength(0)
expect(mockUserState.onUserResolved).toHaveBeenCalled()
})
it('installs the queue stub and injects the snippet script', async () => {
;(window as ConfigWindow).__CONFIG__ = { syftdata_source_id: 'src-123' }
const appendChild = vi.spyOn(document.head, 'appendChild')
try {
const SyftTelemetryProvider = await importProvider()
new SyftTelemetryProvider()
expect(window.syftc).toEqual({ sourceId: 'src-123' })
expect(window.syft?.q).toEqual([])
const appended = appendChild.mock.calls[0]?.[0]
expect(appended).toBeInstanceOf(HTMLScriptElement)
expect((appended as HTMLScriptElement).src).toBe(SYFT_SRC)
} finally {
appendChild.mockRestore()
}
})
it('queues identify calls made before the script loads', async () => {
;(window as ConfigWindow).__CONFIG__ = { syftdata_source_id: 'src-123' }
const SyftTelemetryProvider = await importProvider()
new SyftTelemetryProvider()
window.syft?.identify('user@example.com', { source: 'login' })
expect(window.syft?.q).toContainEqual([
'identify',
'user@example.com',
{ source: 'login' }
])
})
it('preserves an existing window.syft and script (GTM-loaded)', async () => {
;(window as ConfigWindow).__CONFIG__ = { syftdata_source_id: 'src-123' }
const gtmSyft = {
identify: vi.fn(),
signup: vi.fn(),
track: vi.fn(),
page: vi.fn()
}
window.syft = gtmSyft
const gtmScript = document.createElement('script')
gtmScript.src = SYFT_SRC
document.head.appendChild(gtmScript)
const SyftTelemetryProvider = await importProvider()
new SyftTelemetryProvider()
expect(window.syft).toBe(gtmSyft)
expect(querySyftScripts()).toHaveLength(1)
})
it('identifies with email and login source on user resolve', async () => {
;(window as ConfigWindow).__CONFIG__ = { syftdata_source_id: 'src-123' }
mockUserState.userEmail.value = 'user@example.com'
const SyftTelemetryProvider = await importProvider()
new SyftTelemetryProvider()
const resolveCallback = mockUserState.onUserResolved.mock.calls[0][0]
resolveCallback()
expect(window.syft?.q).toContainEqual([
'identify',
'user@example.com',
{ source: 'login' }
])
})
it('skips identify on user resolve when email is unavailable', async () => {
;(window as ConfigWindow).__CONFIG__ = { syftdata_source_id: 'src-123' }
const SyftTelemetryProvider = await importProvider()
new SyftTelemetryProvider()
const resolveCallback = mockUserState.onUserResolved.mock.calls[0][0]
resolveCallback()
expect(window.syft?.q).toEqual([])
})
})
describe('trackAuth', () => {
const installSyftSpy = () => {
const syftSpy = {
identify: vi.fn(),
signup: vi.fn(),
track: vi.fn(),
page: vi.fn()
}
window.syft = syftSpy
return syftSpy
}
it('calls signup for new users with method and signup source', async () => {
const syftSpy = installSyftSpy()
const SyftTelemetryProvider = await importProvider()
new SyftTelemetryProvider().trackAuth({
method: 'google',
is_new_user: true,
email: 'new@example.com'
})
expect(syftSpy.signup).toHaveBeenCalledWith('new@example.com', {
source: 'signup',
method: 'google'
})
expect(syftSpy.identify).not.toHaveBeenCalled()
})
it('calls identify for returning users with login source', async () => {
const syftSpy = installSyftSpy()
const SyftTelemetryProvider = await importProvider()
new SyftTelemetryProvider().trackAuth({
method: 'github',
is_new_user: false,
email: 'back@example.com'
})
expect(syftSpy.identify).toHaveBeenCalledWith('back@example.com', {
source: 'login',
method: 'github'
})
expect(syftSpy.signup).not.toHaveBeenCalled()
})
it('defaults to login source when new vs returning is unknown', async () => {
const syftSpy = installSyftSpy()
const SyftTelemetryProvider = await importProvider()
new SyftTelemetryProvider().trackAuth({
method: 'email',
email: 'who@example.com'
})
expect(syftSpy.identify).toHaveBeenCalledWith('who@example.com', {
source: 'login',
method: 'email'
})
expect(syftSpy.signup).not.toHaveBeenCalled()
})
it('does nothing without an email', async () => {
const syftSpy = installSyftSpy()
const SyftTelemetryProvider = await importProvider()
new SyftTelemetryProvider().trackAuth({
method: 'email',
is_new_user: true
})
expect(syftSpy.signup).not.toHaveBeenCalled()
expect(syftSpy.identify).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,143 @@
/**
* SyftData Telemetry Provider - Cloud Build Implementation
*
* Loads the Syft snippet and identifies logged-in users by email so Syft
* can enrich them (firmographics flow to PostHog via Syft's native
* destination as `sy_*` person properties). Only implements trackAuth -
* page/session capture is handled by Syft's own autotrack.
*
* The loader is idempotent with the Syft tag in GTM: whichever runs
* first wins.
*
* CRITICAL: OSS Build Safety
* Entire file is tree-shaken away in OSS builds (DISTRIBUTION unset).
*/
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import type { AuthMetadata, TelemetryProvider } from '../../types'
const SYFT_SRC = 'https://cdn.sy-d.io/syftnext/syft.umd.js'
type SyftTraits = Record<string, string | number | null | undefined>
interface SyftPendingFetch {
args: unknown[]
resolve: (value: unknown) => void
reject: (reason?: unknown) => void
}
interface SyftClient {
identify: (email: string, traits?: SyftTraits) => void
signup: (email: string, traits?: SyftTraits) => void
track: (event: string, traits?: SyftTraits) => void
page: (...args: unknown[]) => void
q?: unknown[][]
fi?: SyftPendingFetch[]
fetchID?: (...args: unknown[]) => Promise<unknown>
}
declare global {
interface Window {
syft?: SyftClient
syftc?: { sourceId: string }
}
}
/** Pre-load queue stub matching the official Syft loader snippet; the UMD
* script drains `q` and settles `fi` entries once it loads. */
function createSyftStub(): SyftClient {
const q: unknown[][] = []
const fi: SyftPendingFetch[] = []
const enqueue =
(method: string) =>
(...args: unknown[]) => {
q.push([method, ...args])
}
return {
q,
fi,
fetchID: (...args: unknown[]) =>
new Promise((resolve, reject) => {
fi.push({ args, resolve, reject })
}),
identify: enqueue('identify'),
signup: enqueue('signup'),
track: enqueue('track'),
page: enqueue('page')
}
}
let scriptPromise: Promise<void> | null = null
function ensureSyftLoaded(sourceId: string): Promise<void> {
if (scriptPromise) return scriptPromise
scriptPromise = new Promise<void>((resolve, reject) => {
window.syftc = { sourceId }
if (!window.syft) {
window.syft = createSyftStub()
}
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${SYFT_SRC}"]`
)
if (existing) {
resolve()
return
}
const scriptEl = document.createElement('script')
scriptEl.src = SYFT_SRC
scriptEl.async = true
scriptEl.addEventListener('load', () => resolve(), { once: true })
scriptEl.addEventListener(
'error',
() => {
scriptEl.remove()
scriptPromise = null
reject(new Error('Syft script failed to load'))
},
{ once: true }
)
document.head.appendChild(scriptEl)
})
return scriptPromise
}
export class SyftTelemetryProvider implements TelemetryProvider {
constructor() {
const sourceId = window.__CONFIG__?.syftdata_source_id
if (sourceId) {
ensureSyftLoaded(sourceId).catch((error) => {
console.warn('[Syft] snippet failed to load', error)
})
} else {
console.warn('Syft source id not provided in runtime config')
}
// Re-identify on every session resolve so users who were already
// logged in before this shipped still get linked to their Syft
// profile. Session restores cannot distinguish new vs returning,
// so source is 'login'. Registered even without a sourceId because
// the GTM-loaded snippet may still provide window.syft.
const currentUser = useCurrentUser()
currentUser.onUserResolved(() => {
const email = currentUser.userEmail.value
if (email) {
window.syft?.identify(email, { source: 'login' })
}
})
}
/** Identify at the auth action itself - the only moment signup vs
* login ("source") and auth method are knowable. */
trackAuth({ email, is_new_user, method }: AuthMetadata): void {
if (!email) return
if (is_new_user) {
window.syft?.signup(email, { source: 'signup', method })
} else {
window.syft?.identify(email, { source: 'login', method })
}
}
}