Skip to content

Commit 0be0de3

Browse files
committed
enable $web_vitals reporting when cookieless mode is enabled
1 parent 5f95335 commit 0be0de3

3 files changed

Lines changed: 121 additions & 30 deletions

File tree

packages/browser/src/__tests__/extensions/web-vitals.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { PostHog } from '../../posthog-core'
66
import { FlagsResponse, PerformanceCaptureConfig, RemoteConfig, SupportedWebVitalsMetrics } from '../../types'
77
import { assignableWindow } from '../../utils/globals'
88
import { DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS, FIFTEEN_MINUTES_IN_MILLIS } from '../../extensions/web-vitals'
9-
import { WEB_VITALS_ENABLED_SERVER_SIDE, WEB_VITALS_ALLOWED_METRICS } from '../../constants'
9+
import { WEB_VITALS_ENABLED_SERVER_SIDE, WEB_VITALS_ALLOWED_METRICS, COOKIELESS_MODE_FLAG_PROPERTY } from '../../constants'
1010

1111
jest.useFakeTimers()
1212

@@ -242,6 +242,91 @@ describe('web vitals', () => {
242242
}
243243
)
244244

245+
describe('cookieless_mode (no SessionIdManager)', () => {
246+
const expectedEmittedWebVitalsCookieless = (name: string) => ({
247+
$current_url: 'http://localhost/',
248+
timestamp: expect.any(Number),
249+
name: name,
250+
value: 123.45,
251+
extra: 'property',
252+
})
253+
254+
beforeEach(async () => {
255+
beforeSendMock.mockClear()
256+
onLCPCallback = undefined
257+
onCLSCallback = undefined
258+
onFCPCallback = undefined
259+
onINPCallback = undefined
260+
261+
posthog = await createPosthogInstance(uuidv7(), {
262+
before_send: beforeSendMock,
263+
cookieless_mode: 'always',
264+
capture_performance: { web_vitals: true, web_vitals_allowed_metrics: ['CLS', 'FCP'] },
265+
capture_pageview: false,
266+
})
267+
268+
expect(posthog.sessionManager).toBeUndefined()
269+
270+
loadScriptMock.mockImplementation((_ph, _path, callback) => {
271+
assignableWindow.__PosthogExtensions__ = {}
272+
assignableWindow.__PosthogExtensions__.postHogWebVitalsCallbacks = {
273+
onLCP: (cb: any) => {
274+
onLCPCallback = cb
275+
},
276+
onCLS: (cb: any) => {
277+
onCLSCallback = cb
278+
},
279+
onFCP: (cb: any) => {
280+
onFCPCallback = cb
281+
},
282+
onINP: (cb: any) => {
283+
onINPCallback = cb
284+
},
285+
}
286+
callback()
287+
})
288+
289+
assignableWindow.__PosthogExtensions__ = {}
290+
assignableWindow.__PosthogExtensions__.loadExternalDependency = loadScriptMock
291+
292+
posthog.webVitalsAutocapture!.onRemoteConfig({
293+
capturePerformance: { web_vitals: true },
294+
} as unknown as FlagsResponse)
295+
296+
expect(posthog.webVitalsAutocapture!.allowedMetrics).toEqual(['CLS', 'FCP'])
297+
})
298+
299+
it('emits web vitals without nested $session_id or $window_id; sets $cookieless_mode on payload', async () => {
300+
onCLSCallback?.({ name: 'CLS', value: 123.45, extra: 'property' })
301+
onFCPCallback?.({ name: 'FCP', value: 123.45, extra: 'property' })
302+
303+
expect(beforeSendMock).toBeCalledTimes(1)
304+
305+
const payload = beforeSendMock.mock.calls[0][0]
306+
expect(payload.event).toBe('$web_vitals')
307+
expect(payload.properties[COOKIELESS_MODE_FLAG_PROPERTY]).toBe(true)
308+
expect(payload.properties.$web_vitals_CLS_event).toEqual(expectedEmittedWebVitalsCookieless('CLS'))
309+
expect(payload.properties.$web_vitals_FCP_event).toEqual(expectedEmittedWebVitalsCookieless('FCP'))
310+
expect(payload.properties.$web_vitals_CLS_event).not.toHaveProperty('$session_id')
311+
expect(payload.properties.$web_vitals_CLS_event).not.toHaveProperty('$window_id')
312+
expect(payload.properties.$web_vitals_FCP_event).not.toHaveProperty('$session_id')
313+
expect(payload.properties.$web_vitals_FCP_event).not.toHaveProperty('$window_id')
314+
})
315+
316+
it('emits on delayed flush without nested session ids when only one metric is captured', async () => {
317+
onCLSCallback?.({ name: 'CLS', value: 123.45, extra: 'property' })
318+
319+
jest.advanceTimersByTime(DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1)
320+
321+
expect(beforeSendMock).toBeCalledTimes(1)
322+
const payload = beforeSendMock.mock.calls[0][0]
323+
expect(payload.event).toBe('$web_vitals')
324+
expect(payload.properties[COOKIELESS_MODE_FLAG_PROPERTY]).toBe(true)
325+
expect(payload.properties.$web_vitals_CLS_event).not.toHaveProperty('$session_id')
326+
expect(payload.properties.$web_vitals_CLS_event).not.toHaveProperty('$window_id')
327+
})
328+
})
329+
245330
describe('web_vitals_attribution config', () => {
246331
it.each([
247332
[undefined, false],

packages/browser/src/extensions/web-vitals/index.ts

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { PostHog } from '../../posthog-core'
2-
import { PostHogConfig, RemoteConfig, SupportedWebVitalsMetrics } from '../../types'
2+
import { RemoteConfig, SupportedWebVitalsMetrics } from '../../types'
33
import { createLogger } from '../../utils/logger'
44
import { isBoolean, isNullish, isNumber, isUndefined, isObject } from '@posthog/core'
55
import { WEB_VITALS_ALLOWED_METRICS, WEB_VITALS_ENABLED_SERVER_SIDE } from '../../constants'
66
import { assignableWindow, window, location } from '../../utils/globals'
77
import { maskQueryParams } from '../../utils/request-utils'
88
import { PERSONAL_DATA_CAMPAIGN_PARAMS, MASKED } from '../../utils/event-utils'
9+
import { extendArray } from '../../utils'
910

1011
const logger = createLogger('[Web Vitals]')
1112

@@ -30,38 +31,36 @@ export class WebVitalsAutocapture {
3031
this.startIfEnabled()
3132
}
3233

33-
/** Shorthand for performance config to reduce bundle size */
34-
private get _perfConfig(): PostHogConfig['capture_performance'] {
35-
return this._instance.config.capture_performance
36-
}
37-
3834
public get allowedMetrics(): SupportedWebVitalsMetrics[] {
39-
const clientConfigMetricAllowList: SupportedWebVitalsMetrics[] | undefined = isObject(this._perfConfig)
40-
? this._perfConfig?.web_vitals_allowed_metrics
35+
const clientConfigMetricAllowList: SupportedWebVitalsMetrics[] | undefined = isObject(
36+
this._instance.config.capture_performance
37+
)
38+
? this._instance.config.capture_performance?.web_vitals_allowed_metrics
4139
: undefined
4240
return !isNullish(clientConfigMetricAllowList)
4341
? clientConfigMetricAllowList
4442
: this._instance.persistence?.props[WEB_VITALS_ALLOWED_METRICS] || ['CLS', 'FCP', 'INP', 'LCP']
4543
}
4644

4745
public get flushToCaptureTimeoutMs(): number {
48-
const clientConfig: number | undefined = isObject(this._perfConfig)
49-
? this._perfConfig.web_vitals_delayed_flush_ms
46+
const clientConfig: number | undefined = isObject(this._instance.config.capture_performance)
47+
? this._instance.config.capture_performance.web_vitals_delayed_flush_ms
5048
: undefined
5149
return clientConfig || DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS
5250
}
5351

5452
public get useAttribution(): boolean {
55-
const clientConfig: boolean | undefined = isObject(this._perfConfig)
56-
? this._perfConfig.web_vitals_attribution
53+
const clientConfig: boolean | undefined = isObject(this._instance.config.capture_performance)
54+
? this._instance.config.capture_performance.web_vitals_attribution
5755
: undefined
5856
return clientConfig ?? false
5957
}
6058

6159
public get _maxAllowedValue(): number {
6260
const configured =
63-
isObject(this._perfConfig) && isNumber(this._perfConfig.__web_vitals_max_value)
64-
? this._perfConfig.__web_vitals_max_value
61+
isObject(this._instance.config.capture_performance) &&
62+
isNumber(this._instance.config.capture_performance.__web_vitals_max_value)
63+
? this._instance.config.capture_performance.__web_vitals_max_value
6564
: FIFTEEN_MINUTES_IN_MILLIS
6665
// you can set to 0 to disable the check or any value over ten seconds
6766
// 1 milli to 1 minute will be set to 15 minutes, cos that would be a silly low maximum
@@ -77,10 +76,10 @@ export class WebVitalsAutocapture {
7776
}
7877

7978
// Otherwise, check config
80-
const clientConfig = isObject(this._perfConfig)
81-
? this._perfConfig.web_vitals
82-
: isBoolean(this._perfConfig)
83-
? this._perfConfig
79+
const clientConfig = isObject(this._instance.config.capture_performance)
80+
? this._instance.config.capture_performance.web_vitals
81+
: isBoolean(this._instance.config.capture_performance)
82+
? this._instance.config.capture_performance
8483
: undefined
8584
return isBoolean(clientConfig) ? clientConfig : this._enabledServerSide
8685
}
@@ -149,7 +148,7 @@ export class WebVitalsAutocapture {
149148
const customPersonalDataProperties = this._instance.config.custom_personal_data_properties
150149

151150
const paramsToMask = maskPersonalDataProperties
152-
? [...PERSONAL_DATA_CAMPAIGN_PARAMS, ...(customPersonalDataProperties || [])]
151+
? extendArray([], PERSONAL_DATA_CAMPAIGN_PARAMS, customPersonalDataProperties || [])
153152
: []
154153

155154
return maskQueryParams(href, paramsToMask, MASKED)
@@ -177,12 +176,6 @@ export class WebVitalsAutocapture {
177176
}
178177

179178
private _addToBuffer = (metric: any) => {
180-
const sessionIds = this._instance.sessionManager?.checkAndGetSessionAndWindowId(true)
181-
if (isUndefined(sessionIds)) {
182-
logger.error('Could not read session ID. Dropping metrics!')
183-
return
184-
}
185-
186179
this._buffer = this._buffer || { url: undefined, metrics: [], firstMetricTimestamp: undefined }
187180

188181
const $currentUrl = this._currentURL()
@@ -230,13 +223,18 @@ export class WebVitalsAutocapture {
230223
metric.attribution.interactionTargetElement = undefined
231224
}
232225

233-
this._buffer.metrics.push({
226+
const sessionIds = this._instance.sessionManager?.checkAndGetSessionAndWindowId(true)
227+
const bufferedMetric: Record<string, unknown> = {
234228
...metric,
235229
$current_url: $currentUrl,
236-
$session_id: sessionIds.sessionId,
237-
$window_id: sessionIds.windowId,
238230
timestamp: Date.now(),
239-
})
231+
}
232+
if (!isUndefined(sessionIds)) {
233+
bufferedMetric.$session_id = sessionIds.sessionId
234+
bufferedMetric.$window_id = sessionIds.windowId
235+
}
236+
237+
this._buffer.metrics.push(bufferedMetric)
240238

241239
if (this._buffer.metrics.length === this.allowedMetrics.length) {
242240
// we have all allowed metrics

packages/types/src/posthog-config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ export interface PerformanceCaptureConfig {
151151

152152
/**
153153
* Use chrome's web vitals library to wrap fetch and capture web vitals
154+
*
155+
* When `cookieless_mode` is active, there is no client-side SessionIdManager; vitals are still
156+
* captured. Nested `$web_vitals_*_event` payloads omit `$session_id` / `$window_id`; PostHog ingestion assigns
157+
* `$session_id` server-side for cookieless traffic when project cookieless settings are enabled (same as other events).
154158
*/
155159
web_vitals?: boolean
156160

@@ -1561,6 +1565,10 @@ export interface PostHogConfig {
15611565
/**
15621566
* Enables cookieless mode. In this mode, PostHog will not set any cookies, or use session or local storage. User
15631567
* identity is handled by generating a privacy-preserving hash on PostHog's servers.
1568+
*
1569+
* Web Vitals (`capture_performance.web_vitals`) are supported: metrics are sent without client session IDs;
1570+
* ingestion assigns `$session_id` when cookieless mode is enabled for the project.
1571+
*
15641572
* - 'always' - enable cookieless mode immediately on startup, use this if you do not intend to show a cookie banner
15651573
* - 'on_reject' - enable cookieless mode only if the user rejects cookies, use this if you want to show a cookie banner. If the user accepts cookies, cookieless mode will not be used, and PostHog will use cookies and local storage as usual.
15661574
*

0 commit comments

Comments
 (0)