diff --git a/global.d.ts b/global.d.ts index 292664c372..4336d06022 100644 --- a/global.d.ts +++ b/global.d.ts @@ -59,6 +59,11 @@ interface SyftDataClient { fetchID?: (...args: unknown[]) => Promise } +/** Installed by the Syft UMD instead of SyftDataClient when telemetry is opted out */ +interface SyftDisabledClient { + enable: () => void +} + interface Window { __CONFIG__: { gtm_container_id?: string @@ -96,8 +101,8 @@ interface Window { } dataLayer?: Array> gtag?: GtagFunction - syft?: SyftDataClient - syftc?: { sourceId: string } + syft?: SyftDataClient | SyftDisabledClient + syftc?: { sourceId?: string; enabled?: boolean } ire_o?: string ire?: ImpactQueueFunction rewardful?: RewardfulQueueFunction diff --git a/src/platform/telemetry/providers/cloud/SyftTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/SyftTelemetryProvider.test.ts index 7758db4ac4..7a6dfa78f5 100644 --- a/src/platform/telemetry/providers/cloud/SyftTelemetryProvider.test.ts +++ b/src/platform/telemetry/providers/cloud/SyftTelemetryProvider.test.ts @@ -43,6 +43,14 @@ function mockScriptAppend() { .mockImplementation((node: T) => node) } +function syftStub(): SyftDataClient { + const syft = window.syft + if (!syft || !('identify' in syft)) { + throw new Error('Expected a full Syft client on window') + } + return syft +} + function failScript( appendChild: ReturnType, index: number @@ -109,7 +117,7 @@ describe('SyftTelemetryProvider', () => { }) expect(appendChild).toHaveBeenCalledTimes(2) - expect(window.syft?.q).toContainEqual([ + expect(syftStub().q).toContainEqual([ 'identify', 'retry@example.com', { source: 'login', method: 'email' } @@ -131,7 +139,7 @@ describe('SyftTelemetryProvider', () => { await Promise.resolve() expect(appendChild).toHaveBeenCalledTimes(2) - expect(window.syft?.q).toContainEqual([ + expect(syftStub().q).toContainEqual([ 'identify', 'new@example.com', { source: 'signup', method: 'google' } @@ -158,7 +166,7 @@ describe('SyftTelemetryProvider', () => { provider.trackUserLoggedIn() expect(appendChild).toHaveBeenCalledTimes(3) - expect(window.syft?.q).toContainEqual([ + expect(syftStub().q).toContainEqual([ 'identify', 'restored@example.com', { source: 'login' } @@ -191,7 +199,7 @@ describe('SyftTelemetryProvider', () => { const SyftTelemetryProvider = await importProvider() new SyftTelemetryProvider() - const pending = window.syft?.fetchID?.('anonymousId') + const pending = syftStub().fetchID?.('anonymousId') failScript(appendChild, 0) @@ -215,13 +223,43 @@ describe('SyftTelemetryProvider', () => { expect(appendChild).toHaveBeenCalledTimes(1) expect(window.syftc).toEqual({ sourceId: 'src-123' }) - expect(window.syft?.q).toContainEqual([ + expect(syftStub().q).toContainEqual([ 'identify', 'late@example.com', { source: 'login', method: 'email' } ]) }) + it('preserves an existing opt-out flag when writing the source id', async () => { + mockRemoteConfig.value = { syftdata_source_id: 'src-123' } + window.syftc = { enabled: false } + mockScriptAppend() + const SyftTelemetryProvider = await importProvider() + + new SyftTelemetryProvider() + + expect(window.syftc).toEqual({ enabled: false, sourceId: 'src-123' }) + }) + + it('skips identify when Syft installed its disabled-mode client', async () => { + mockRemoteConfig.value = { syftdata_source_id: 'src-123' } + const appendChild = mockScriptAppend() + const disabledClient = { enable: vi.fn() } + window.syft = disabledClient + const SyftTelemetryProvider = await importProvider() + const provider = new SyftTelemetryProvider() + + expect(() => + provider.trackAuth({ + email: 'optedout@example.com', + is_new_user: false, + method: 'email' + }) + ).not.toThrow() + expect(window.syft).toBe(disabledClient) + expect(appendChild).not.toHaveBeenCalled() + }) + it('does not touch the current user store during construction', async () => { mockRemoteConfig.value = { syftdata_source_id: 'src-123' } mockScriptAppend() @@ -255,7 +293,7 @@ describe('SyftTelemetryProvider', () => { method: 'google' }) - expect(window.syft?.q).toContainEqual([ + expect(syftStub().q).toContainEqual([ 'identify', 'new@example.com', { source: 'signup', method: 'google' } @@ -316,7 +354,7 @@ describe('SyftTelemetryProvider', () => { new SyftTelemetryProvider().trackUserLoggedIn() expect(mockCurrentUser.useCurrentUser).toHaveBeenCalled() - expect(window.syft?.q).toContainEqual([ + expect(syftStub().q).toContainEqual([ 'identify', 'restored@example.com', { source: 'login' } diff --git a/src/platform/telemetry/providers/cloud/SyftTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/SyftTelemetryProvider.ts index b61e289773..e17bd61029 100644 --- a/src/platform/telemetry/providers/cloud/SyftTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/SyftTelemetryProvider.ts @@ -12,8 +12,9 @@ let lastIdentifiedEmail: string | null = null let pendingIdentify: { email: string; traits: SyftDataTraits } | null = null let hasReplayedIdentify = false -const loadSyftSdk = createScriptLoader(SYFT_SRC, () => - window.syft && window.syft !== currentStub ? window.syft : null +const loadSyftSdk = createScriptLoader( + SYFT_SRC, + () => (window.syft && window.syft !== currentStub ? window.syft : null) ) function createTraits( @@ -50,11 +51,11 @@ function createSyftStub(): SyftDataClient { } } -function ensureSyftClient(): SyftDataClient | null { +function ensureSyftClient(): SyftDataClient | SyftDisabledClient | null { const sourceId = remoteConfig.value.syftdata_source_id if (!sourceId) return window.syft ?? null - window.syftc = { sourceId } + window.syftc = { ...window.syftc, sourceId } if (window.syft) return window.syft const stub = createSyftStub() @@ -92,7 +93,7 @@ function replayPendingIdentify(): void { function identifyUser(email: string, traits: SyftDataTraits): void { const syft = ensureSyftClient() - if (!syft) return + if (!syft || !('identify' in syft)) return syft.identify(email, traits) lastIdentifiedEmail = email