Skip to content

Commit

Permalink
feat: web vitals 3000 (#1401)
Browse files Browse the repository at this point in the history
* take a step

* fiddling

* more

* add INP attribution

* fix

* fix

* fix

* remove sampling
  • Loading branch information
pauldambra authored Sep 9, 2024
1 parent 34204f4 commit b26ff61
Show file tree
Hide file tree
Showing 5 changed files with 190 additions and 110 deletions.
222 changes: 127 additions & 95 deletions src/__tests__/extensions/web-vitals.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createPosthogInstance } from '../helpers/posthog-instance'
import { uuidv7 } from '../../uuidv7'
import { PostHog } from '../../posthog-core'
import { DecideResponse } from '../../types'
import { DecideResponse, SupportedWebVitalsMetrics } from '../../types'
import { assignableWindow } from '../../utils/globals'
import { FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS, FIFTEEN_MINUTES_IN_MILLIS } from '../../extensions/web-vitals'

Expand All @@ -17,16 +17,6 @@ describe('web vitals', () => {
let onINPCallback: ((metric: Record<string, any>) => void) | undefined = undefined
const loadScriptMock = jest.fn()

const randomlyAddAMetric = (
metricName: string = 'metric',
metricValue: number = 600.1,
metricProperties: Record<string, any> = {}
) => {
const callbacks = [onLCPCallback, onCLSCallback, onFCPCallback, onINPCallback]
const randomIndex = Math.floor(Math.random() * callbacks.length)
callbacks[randomIndex]?.({ name: metricName, value: metricValue, ...metricProperties })
}

const emitAllMetrics = () => {
onLCPCallback?.({ name: 'LCP', value: 123.45, extra: 'property' })
onCLSCallback?.({ name: 'CLS', value: 123.45, extra: 'property' })
Expand All @@ -44,112 +34,154 @@ describe('web vitals', () => {
extra: 'property',
})

describe('the behaviour', () => {
beforeEach(async () => {
posthog = await createPosthogInstance(uuidv7(), {
_onCapture: onCapture,
capture_performance: { web_vitals: true },
// sometimes pageviews sneak in and make asserting on mock capture tricky
capture_pageview: false,
})
describe.each([
[
undefined,
['CLS', 'FCP', 'INP', 'LCP'] as SupportedWebVitalsMetrics[],
{
$web_vitals_LCP_event: expectedEmittedWebVitals('LCP'),
$web_vitals_LCP_value: 123.45,
$web_vitals_CLS_event: expectedEmittedWebVitals('CLS'),
$web_vitals_CLS_value: 123.45,
$web_vitals_FCP_event: expectedEmittedWebVitals('FCP'),
$web_vitals_FCP_value: 123.45,
$web_vitals_INP_event: expectedEmittedWebVitals('INP'),
$web_vitals_INP_value: 123.45,
},
],
[
['CLS', 'FCP', 'INP', 'LCP'] as SupportedWebVitalsMetrics[],
['CLS', 'FCP', 'INP', 'LCP'] as SupportedWebVitalsMetrics[],
{
$web_vitals_LCP_event: expectedEmittedWebVitals('LCP'),
$web_vitals_LCP_value: 123.45,
$web_vitals_CLS_event: expectedEmittedWebVitals('CLS'),
$web_vitals_CLS_value: 123.45,
$web_vitals_FCP_event: expectedEmittedWebVitals('FCP'),
$web_vitals_FCP_value: 123.45,
$web_vitals_INP_event: expectedEmittedWebVitals('INP'),
$web_vitals_INP_value: 123.45,
},
],
[
['CLS', 'FCP'] as SupportedWebVitalsMetrics[],
['CLS', 'FCP'] as SupportedWebVitalsMetrics[],
{
$web_vitals_CLS_event: expectedEmittedWebVitals('CLS'),
$web_vitals_CLS_value: 123.45,
$web_vitals_FCP_event: expectedEmittedWebVitals('FCP'),
$web_vitals_FCP_value: 123.45,
},
],
])(
'the behaviour when client config is %s',
(
clientConfig: SupportedWebVitalsMetrics[] | undefined,
expectedAllowedMetrics: SupportedWebVitalsMetrics[],
expectedProperties: Record<string, any>
) => {
beforeEach(async () => {
onCapture.mockClear()
posthog = await createPosthogInstance(uuidv7(), {
_onCapture: onCapture,
capture_performance: { web_vitals: true, web_vitals_allowed_metrics: clientConfig },
// sometimes pageviews sneak in and make asserting on mock capture tricky
capture_pageview: false,
})

loadScriptMock.mockImplementation((_path, callback) => {
// we need a set of fake web vitals handlers, so we can manually trigger the events
assignableWindow.__PosthogExtensions__ = {}
assignableWindow.__PosthogExtensions__.postHogWebVitalsCallbacks = {
onLCP: (cb: any) => {
onLCPCallback = cb
},
onCLS: (cb: any) => {
onCLSCallback = cb
},
onFCP: (cb: any) => {
onFCPCallback = cb
},
onINP: (cb: any) => {
onINPCallback = cb
},
}
callback()
})

posthog.requestRouter.loadScript = loadScriptMock

// need to force this to get the web vitals script loaded
posthog.webVitalsAutocapture!.afterDecideResponse({
capturePerformance: { web_vitals: true },
} as unknown as DecideResponse)

loadScriptMock.mockImplementation((_path, callback) => {
// we need a set of fake web vitals handlers, so we can manually trigger the events
assignableWindow.postHogWebVitalsCallbacks = {
onLCP: (cb: any) => {
onLCPCallback = cb
},
onCLS: (cb: any) => {
onCLSCallback = cb
},
onFCP: (cb: any) => {
onFCPCallback = cb
},
onINP: (cb: any) => {
onINPCallback = cb
},
}
callback()
expect(posthog.webVitalsAutocapture.allowedMetrics).toEqual(expectedAllowedMetrics)
})

posthog.requestRouter.loadScript = loadScriptMock
it('should emit when all allowed metrics are captured', async () => {
emitAllMetrics()

// need to force this to get the web vitals script loaded
posthog.webVitalsAutocapture!.afterDecideResponse({
capturePerformance: { web_vitals: true },
} as unknown as DecideResponse)
})
expect(onCapture).toBeCalledTimes(1)

it('should emit when all 4 metrics are captured', async () => {
emitAllMetrics()

expect(onCapture).toBeCalledTimes(1)

expect(onCapture.mock.lastCall).toMatchObject([
'$web_vitals',
{
event: '$web_vitals',
properties: {
$web_vitals_LCP_event: expectedEmittedWebVitals('LCP'),
$web_vitals_LCP_value: 123.45,
$web_vitals_CLS_event: expectedEmittedWebVitals('CLS'),
$web_vitals_CLS_value: 123.45,
$web_vitals_FCP_event: expectedEmittedWebVitals('FCP'),
$web_vitals_FCP_value: 123.45,
$web_vitals_INP_event: expectedEmittedWebVitals('INP'),
$web_vitals_INP_value: 123.45,
expect(onCapture.mock.lastCall).toMatchObject([
'$web_vitals',
{
event: '$web_vitals',
properties: expectedProperties,
},
},
])
})
])
})

it('should emit after 8 seconds even when only 1 to 3 metrics captured', async () => {
randomlyAddAMetric('LCP', 123.45, { extra: 'property' })
it('should emit after 8 seconds even when only 1 to 3 metrics captured', async () => {
onCLSCallback?.({ name: 'CLS', value: 123.45, extra: 'property' })

expect(onCapture).toBeCalledTimes(0)
expect(onCapture).toBeCalledTimes(0)

jest.advanceTimersByTime(FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1)
jest.advanceTimersByTime(FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1)

// for some reason advancing the timer emits a $pageview event as well 🤷
// expect(onCapture).toBeCalledTimes(2)
expect(onCapture.mock.lastCall).toMatchObject([
'$web_vitals',
{
event: '$web_vitals',
properties: {
$web_vitals_LCP_event: expectedEmittedWebVitals('LCP'),
$web_vitals_LCP_value: 123.45,
// for some reason advancing the timer emits a $pageview event as well 🤷
// expect(onCapture).toBeCalledTimes(2)
expect(onCapture.mock.lastCall).toMatchObject([
'$web_vitals',
{
event: '$web_vitals',
properties: {
$web_vitals_CLS_event: expectedEmittedWebVitals('CLS'),
$web_vitals_CLS_value: 123.45,
},
},
},
])
})
])
})

it('should ignore a ridiculous value', async () => {
randomlyAddAMetric('LCP', FIFTEEN_MINUTES_IN_MILLIS, { extra: 'property' })
it('should ignore a ridiculous value', async () => {
onCLSCallback?.({ name: 'CLS', value: FIFTEEN_MINUTES_IN_MILLIS, extra: 'property' })

expect(onCapture).toBeCalledTimes(0)
expect(onCapture).toBeCalledTimes(0)

jest.advanceTimersByTime(FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1)
jest.advanceTimersByTime(FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1)

expect(onCapture).toBeCalledTimes(0)
})
expect(onCapture.mock.calls).toEqual([])
})

it('can be configured not to ignore a ridiculous value', async () => {
posthog.config.capture_performance = { __web_vitals_max_value: 0 }
randomlyAddAMetric('LCP', FIFTEEN_MINUTES_IN_MILLIS, { extra: 'property' })
it('can be configured not to ignore a ridiculous value', async () => {
posthog.config.capture_performance = { __web_vitals_max_value: 0 }
onCLSCallback?.({ name: 'CLS', value: FIFTEEN_MINUTES_IN_MILLIS, extra: 'property' })

expect(onCapture).toBeCalledTimes(0)
expect(onCapture).toBeCalledTimes(0)

jest.advanceTimersByTime(FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1)
jest.advanceTimersByTime(FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1)

expect(onCapture).toBeCalledTimes(1)
})
})
expect(onCapture).toBeCalledTimes(1)
})
}
)

describe('afterDecideResponse()', () => {
beforeEach(async () => {
// we need a set of fake web vitals handlers so we can manually trigger the events
assignableWindow.postHogWebVitalsCallbacks = {
// we need a set of fake web vitals handlers, so we can manually trigger the events
assignableWindow.__PosthogExtensions__ = {}
assignableWindow.__PosthogExtensions__.postHogWebVitalsCallbacks = {
onLCP: (cb: any) => {
onLCPCallback = cb
},
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const HEATMAPS_ENABLED_SERVER_SIDE = '$heatmaps_enabled_server_side'
export const EXCEPTION_CAPTURE_ENABLED_SERVER_SIDE = '$exception_capture_enabled_server_side'
export const EXCEPTION_CAPTURE_ENDPOINT_SUFFIX = '$exception_capture_endpoint_suffix'
export const WEB_VITALS_ENABLED_SERVER_SIDE = '$web_vitals_enabled_server_side'
export const WEB_VITALS_ALLOWED_METRICS = '$web_vitals_allowed_metrics'
export const SESSION_RECORDING_ENABLED_SERVER_SIDE = '$session_recording_enabled_server_side'
export const CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE = '$console_log_recording_enabled_server_side'
export const SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE = '$session_recording_network_payload_capture'
Expand Down
8 changes: 4 additions & 4 deletions src/entrypoints/web-vitals.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { onLCP, onINP, onCLS, onFCP } from 'web-vitals'
import { onLCP, onCLS, onFCP } from 'web-vitals'
import { onINP } from 'web-vitals/attribution'
import { assignableWindow } from '../utils/globals'

// TODO export types here as well?

const postHogWebVitalsCallbacks = {
onLCP,
onCLS,
onFCP,
onINP,
}

assignableWindow.postHogWebVitalsCallbacks = postHogWebVitalsCallbacks
assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {}
assignableWindow.__PosthogExtensions__.postHogWebVitalsCallbacks = postHogWebVitalsCallbacks

export default postHogWebVitalsCallbacks
Loading

0 comments on commit b26ff61

Please sign in to comment.