Skip to content

Commit 3c909b3

Browse files
authored
fix(privacy): honor browser telemetry preferences (#7583)
1 parent d482526 commit 3c909b3

23 files changed

Lines changed: 1183 additions & 265 deletions

apps/docs/content/docs/platform/self-hosting/observability.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,24 +78,24 @@ app:
7878

7979
See [Security](/platform/self-hosting/security) for the `INTERNAL_API_BASE_URL` requirement — the path fails closed without a cluster-reachable value.
8080

81-
## Anonymous telemetry
81+
## Telemetry
8282

83-
Whether anonymous telemetry runs depends on how you deploy. A Docker Compose or source deployment sends it unless you turn it off, so a deployment with an egress policy should decide about this deliberately rather than inherit the default.
83+
Whether telemetry runs depends on how you deploy. A Docker Compose or source deployment enables server telemetry unless you turn it off, so a deployment with an egress policy should decide about this deliberately rather than inherit the default.
8484

8585
Telemetry has two halves that are decided at different times.
8686

8787
| Half | When it is decided | Default |
8888
|---|---|---|
8989
| Server SDK | Runtime | **Off** on Helm (`app.envDefaults` sets `NEXT_TELEMETRY_DISABLED: "1"`); **on** for Docker Compose and source runs, which set nothing |
90-
| Browser | `next build` | **Off** in every published image — the value is inlined at build time, and no runtime variable changes it |
90+
| Browser | Server setting at page render, then browser preferences | Disabled when `NEXT_TELEMETRY_DISABLED=1`; otherwise waits for session resolution and the signed-in user's saved preference. Hosted Sim also requires the applicable analytics permission. |
9191

9292
Neither half gates `POST /api/telemetry`, the relay that forwards browser events. See the warning below.
9393

9494
When the server SDK runs it exports three OTLP signals to the same base endpoint: traces at `/v1/traces`, metrics at `/v1/metrics`, and application **log records** at `/v1/logs`. Sim's own telemetry is scoped to feature-usage statistics, error rates, performance metrics, and AI/LLM operation traces — not workflow content or outputs, API keys, or geolocation.
9595

9696
The log stream is different in kind: it carries every line at or above `LOG_LEVEL` with its structured metadata, error messages and stack traces, which can include user ids, emails and URLs. That matters if you raise `LOG_LEVEL` as suggested above, and it is a reason to point the exporter at your own collector rather than leaving it at the default.
9797

98-
`NEXT_TELEMETRY_DISABLED=1` stops the server's OpenTelemetry SDK, which returns before it initializes, so it ends the app's own OTLP export to any endpoint. It does not affect Trigger.dev task telemetry, configured separately below. The **Settings → Privacy → Allow anonymous telemetry** toggle is browser-side only and does not stop server spans.
98+
`NEXT_TELEMETRY_DISABLED=1` stops the server's OpenTelemetry SDK and disables browser collection on newly loaded pages. It does not affect Trigger.dev task telemetry, configured separately below. The **Settings → General → Privacy → Allow browser telemetry** toggle stops optional browser performance and error diagnostics, discards queued events, and remembers the choice. It does not stop server spans.
9999

100100
<Callout type="warn">
101101
On Helm, `NEXT_TELEMETRY_DISABLED` is what you must **clear** before any tracing works — including tracing to your own collector. Override it with `null` to remove the key entirely.

apps/sim/app/(landing)/privacy/privacy-content.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
264264
'Behavioral remarketing',
265265
'Cookie and pixel identifiers, browser and Device data, campaign attribution, and website interaction data',
266266
'Consent — Article 6(1)(a)',
267-
'Marketing technologies are disabled until the Marketing category is accepted. Consent may be changed or withdrawn through the cookie preferences link.',
267+
'Marketing cookies and ad personalization require Marketing consent. Google Ads may load earlier with ad storage denied and send limited cookieless consent signals. Consent may be changed or withdrawn through the cookie preferences link.',
268268
],
269269
[
270270
'Retaining transaction and tax records',
@@ -576,7 +576,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = {
576576
{
577577
kind: 'paragraph',
578578
content: richText(
579-
'The Company uses Google Ads, Twitter, and Facebook remarketing services to advertise on third-party websites after You visit the Service. These services operate through non-essential Cookies and similar technologies. They are activated only after You give consent to the Marketing category in the cookie banner. No marketing Cookie is set before that consent.'
579+
'The Company uses Google Ads, Twitter, and Facebook remarketing services to advertise on third-party websites after You visit the Service. These services operate through non-essential Cookies and similar technologies. Marketing Cookies and ad personalization are enabled only after You give consent to the Marketing category in the cookie banner. Google Ads may load before that consent with ad storage denied and send limited cookieless consent signals, as described in the Cookie Policy.'
580580
),
581581
},
582582
{

apps/sim/app/_shell/consent/consent-banner.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ export function ConsentBanner() {
5454
<div className='flex flex-col gap-1'>
5555
<p className='text-[var(--text-body)] text-sm leading-5'>Cookies</p>
5656
<p className='text-[var(--text-muted)] text-small leading-[18px]'>
57-
We use cookies to run Sim, understand how it is used, and improve it. Read our{' '}
57+
Necessary cookies keep Sim working. Optional cookies help us understand usage, measure
58+
campaigns, and personalize ads on other sites. You can change or withdraw consent at
59+
any time in Privacy settings or through our{' '}
5860
<Link href='/cookie-policy' className={CONSENT_LINK_CLASS}>
5961
Cookie Policy
6062
</Link>

apps/sim/app/_shell/consent/consent-preferences.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const CONSENT_CATEGORY_COPY: Record<string, ConsentCategoryCopy | undefined> = {
3838
},
3939
marketing: {
4040
title: 'Marketing',
41-
description: 'Measures which campaigns bring builders to Sim.',
41+
description: 'Measures campaigns and personalizes ads on other sites.',
4242
},
4343
} satisfies Record<ConsentCategory, ConsentCategoryCopy>
4444

apps/sim/app/_shell/consent/consent-store-provider.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe('ConsentStoreProvider', () => {
5252
expect.objectContaining({ id: 'ahrefs-analytics', category: 'measurement' }),
5353
],
5454
store: {
55+
storageConfig: { defaultExpiryDays: 365 },
5556
reloadOnConsentRevoked: true,
5657
iframeBlockerConfig: { disableAutomaticBlocking: true },
5758
},

apps/sim/app/_shell/consent/consent-store-provider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
DEV_CONSENT_COUNTRY,
99
} from '@/lib/consent/constants'
1010
import { GLOBAL_CONSENT_SCRIPTS } from '@/lib/consent/scripts'
11+
import { CONSENT_STORAGE_CONFIG } from '@/lib/consent/storage'
1112

1213
/**
1314
* Imported from `@c15t/nextjs/headless`, not the package root: the headless
@@ -29,6 +30,7 @@ const CONSENT_OPTIONS = {
2930
consentCategories: [...CONSENT_CATEGORIES],
3031
scripts: [...GLOBAL_CONSENT_SCRIPTS],
3132
store: {
33+
storageConfig: CONSENT_STORAGE_CONFIG,
3234
reloadOnConsentRevoked: true,
3335
iframeBlockerConfig: { disableAutomaticBlocking: true },
3436
},
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { session, consent, settings, start, stop } = vi.hoisted(() => {
9+
const stop = vi.fn()
10+
return {
11+
session: {
12+
data: null as { user: { id: string } } | null,
13+
isPending: true,
14+
error: null as Error | null,
15+
},
16+
consent: { isResolved: false, measurement: false },
17+
settings: { data: undefined as { telemetryEnabled: boolean } | undefined, isError: false },
18+
start: vi.fn(() => stop),
19+
stop,
20+
}
21+
})
22+
23+
vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => session }))
24+
vi.mock('@/lib/consent/tracking-consent', () => ({ useTrackingConsent: () => consent }))
25+
vi.mock('@/hooks/queries/general-settings', () => ({ useGeneralSettings: () => settings }))
26+
vi.mock('@/lib/telemetry/browser', () => ({ startBrowserTelemetry: start }))
27+
28+
import { setBrowserTelemetryPreference } from '@/lib/telemetry/browser-preference'
29+
import { BrowserTelemetry } from '@/app/_shell/providers/browser-telemetry'
30+
31+
let root: Root
32+
let container: HTMLDivElement
33+
34+
function render(disabled = false, consentRequired = true) {
35+
act(() => root.render(<BrowserTelemetry disabled={disabled} consentRequired={consentRequired} />))
36+
}
37+
38+
beforeEach(() => {
39+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
40+
container = document.createElement('div')
41+
root = createRoot(container)
42+
localStorage.clear()
43+
})
44+
45+
afterEach(() => {
46+
act(() => root.unmount())
47+
session.data = null
48+
session.isPending = true
49+
session.error = null
50+
consent.isResolved = false
51+
consent.measurement = false
52+
settings.data = undefined
53+
settings.isError = false
54+
setBrowserTelemetryPreference(true)
55+
localStorage.clear()
56+
vi.clearAllMocks()
57+
})
58+
59+
describe('BrowserTelemetry permission boundary', () => {
60+
it('waits for consent and saved account settings, then stops on either withdrawal', () => {
61+
render()
62+
session.isPending = false
63+
session.data = { user: { id: 'user-1' } }
64+
render()
65+
consent.isResolved = true
66+
consent.measurement = true
67+
render()
68+
expect(start).not.toHaveBeenCalled()
69+
70+
settings.data = { telemetryEnabled: false }
71+
render()
72+
expect(start).not.toHaveBeenCalled()
73+
settings.data.telemetryEnabled = true
74+
render()
75+
expect(start).toHaveBeenCalledTimes(1)
76+
settings.data.telemetryEnabled = false
77+
render()
78+
expect(stop).toHaveBeenCalledTimes(1)
79+
settings.data.telemetryEnabled = true
80+
render()
81+
consent.measurement = false
82+
render()
83+
expect(stop).toHaveBeenCalledTimes(2)
84+
})
85+
86+
it('honors the deployment disable switch and does not require hosted consent when self-hosted', () => {
87+
session.isPending = false
88+
render(true, false)
89+
expect(start).not.toHaveBeenCalled()
90+
render(false, false)
91+
expect(start).toHaveBeenCalledTimes(1)
92+
render(true, false)
93+
expect(stop).toHaveBeenCalledTimes(1)
94+
})
95+
96+
it('keeps a saved browser refusal effective and observes local changes', () => {
97+
session.isPending = false
98+
consent.isResolved = true
99+
consent.measurement = true
100+
setBrowserTelemetryPreference(false)
101+
render()
102+
expect(start).not.toHaveBeenCalled()
103+
act(() => setBrowserTelemetryPreference(true))
104+
expect(start).toHaveBeenCalledTimes(1)
105+
act(() => setBrowserTelemetryPreference(false))
106+
expect(stop).toHaveBeenCalledTimes(1)
107+
})
108+
109+
it('stops on session or settings errors even with previously allowed data', () => {
110+
session.isPending = false
111+
session.data = { user: { id: 'user-1' } }
112+
consent.isResolved = true
113+
consent.measurement = true
114+
settings.data = { telemetryEnabled: true }
115+
render()
116+
settings.isError = true
117+
render()
118+
expect(stop).toHaveBeenCalledTimes(1)
119+
settings.isError = false
120+
session.error = new Error('Session unavailable')
121+
render()
122+
expect(start).toHaveBeenCalledTimes(1)
123+
})
124+
125+
it('keeps newer Analytics withdrawals effective across restarts without reacting to Marketing', () => {
126+
session.isPending = false
127+
consent.isResolved = true
128+
consent.measurement = true
129+
render()
130+
const save = (measurement: boolean, marketing: boolean) => {
131+
const newValue = JSON.stringify({ consents: { measurement, marketing } })
132+
act(() => {
133+
localStorage.setItem('c15t', newValue)
134+
window.dispatchEvent(new StorageEvent('storage', { key: 'c15t', newValue }))
135+
})
136+
}
137+
save(true, false)
138+
expect(stop).not.toHaveBeenCalled()
139+
save(false, false)
140+
expect(stop).toHaveBeenCalledTimes(1)
141+
act(() => setBrowserTelemetryPreference(false))
142+
act(() => setBrowserTelemetryPreference(true))
143+
expect(start).toHaveBeenCalledTimes(1)
144+
act(() => {
145+
localStorage.clear()
146+
window.dispatchEvent(new StorageEvent('storage', { key: null }))
147+
})
148+
render()
149+
expect(start).toHaveBeenCalledTimes(1)
150+
save(true, false)
151+
expect(start).toHaveBeenCalledTimes(2)
152+
})
153+
})
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
'use client'
2+
3+
import { useEffect, useSyncExternalStore } from 'react'
4+
import { useSession } from '@/lib/auth/auth-client'
5+
import {
6+
getStoredMeasurementPermission,
7+
subscribeStoredMeasurementPermission,
8+
} from '@/lib/consent/measurement-permission'
9+
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
10+
import { startBrowserTelemetry } from '@/lib/telemetry/browser'
11+
import {
12+
getBrowserTelemetryPreference,
13+
subscribeBrowserTelemetryPreference,
14+
} from '@/lib/telemetry/browser-preference'
15+
import { useGeneralSettings } from '@/hooks/queries/general-settings'
16+
17+
interface BrowserTelemetryProps {
18+
disabled: boolean
19+
consentRequired: boolean
20+
}
21+
22+
interface TelemetryCaptureProps {
23+
enabled: boolean
24+
consentRequired: boolean
25+
}
26+
27+
interface AccountTelemetryProps {
28+
consentRequired: boolean
29+
}
30+
31+
function getServerPreference(): boolean {
32+
return false
33+
}
34+
35+
function TelemetryCapture({ enabled, consentRequired }: TelemetryCaptureProps) {
36+
const browserAllowsTelemetry = useSyncExternalStore(
37+
subscribeBrowserTelemetryPreference,
38+
getBrowserTelemetryPreference,
39+
getServerPreference
40+
)
41+
42+
useEffect(() => {
43+
if (enabled && browserAllowsTelemetry) return startBrowserTelemetry(consentRequired)
44+
}, [enabled, browserAllowsTelemetry, consentRequired])
45+
46+
return null
47+
}
48+
49+
function AccountTelemetry({ consentRequired }: AccountTelemetryProps) {
50+
const { data, isError } = useGeneralSettings()
51+
return (
52+
<TelemetryCapture
53+
enabled={!isError && data?.telemetryEnabled === true}
54+
consentRequired={consentRequired}
55+
/>
56+
)
57+
}
58+
59+
/** Collects only after session, account preferences, and applicable cookie consent have resolved. */
60+
export function BrowserTelemetry({ disabled, consentRequired }: BrowserTelemetryProps) {
61+
const { data, isPending, error } = useSession()
62+
const { isResolved, measurement } = useTrackingConsent()
63+
const storedPermission = useSyncExternalStore(
64+
subscribeStoredMeasurementPermission,
65+
getStoredMeasurementPermission,
66+
getServerPreference
67+
)
68+
if (
69+
disabled ||
70+
isPending ||
71+
error ||
72+
(consentRequired && (!isResolved || !measurement || !storedPermission))
73+
) {
74+
return null
75+
}
76+
77+
return data?.user ? (
78+
<AccountTelemetry key={data.user.id} consentRequired={consentRequired} />
79+
) : (
80+
<TelemetryCapture enabled consentRequired={consentRequired} />
81+
)
82+
}

apps/sim/app/api/users/me/settings/route.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ describe('PATCH /api/users/me/settings', () => {
2929
expect(await response.json()).toEqual({ success: true })
3030
})
3131

32+
it('does not acknowledge a privacy update when the session has expired', async () => {
33+
mockGetSession.mockResolvedValue(null)
34+
35+
const response = await PATCH(createMockRequest('PATCH', { telemetryEnabled: false }))
36+
37+
expect(response.status).toBe(401)
38+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
39+
})
40+
3241
/**
3342
* The regression this guards: the catch answered `{ success: true }` with 200, so
3443
* `useUpdateGeneralSetting`'s optimistic rollback in `onError` could never run —
@@ -58,8 +67,20 @@ describe('GET /api/users/me/settings', () => {
5867

5968
expect(response.status).toBe(200)
6069
await expect(response.json()).resolves.toMatchObject({
61-
data: { theme: 'system', autoConnect: true },
70+
data: { theme: 'system', autoConnect: true, telemetryEnabled: false },
6271
})
6372
expect(dbChainMockFns.select).not.toHaveBeenCalled()
6473
})
74+
75+
it('does not replace unavailable saved preferences with permission to collect', async () => {
76+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
77+
dbChainMockFns.select.mockImplementationOnce(() => {
78+
throw new Error('Database unavailable')
79+
})
80+
81+
const response = await GET()
82+
83+
expect(response.status).toBe(500)
84+
await expect(response.json()).resolves.toEqual({ error: 'Failed to load settings' })
85+
})
6586
})

0 commit comments

Comments
 (0)