Skip to content

Commit

Permalink
feat: add lazily loaded web vitals collection (#1203)
Browse files Browse the repository at this point in the history
  • Loading branch information
pauldambra authored Jun 25, 2024
1 parent c6002ab commit d08d318
Show file tree
Hide file tree
Showing 15 changed files with 492 additions and 43 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
],
"dependencies": {
"fflate": "^0.4.8",
"preact": "^10.19.3"
"preact": "^10.19.3",
"web-vitals": "^4.0.1"
},
"devDependencies": {
"@babel/core": "7.18.9",
Expand Down
14 changes: 11 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ export default [
],
plugins: [...plugins],
},
{
input: 'src/loader-web-vitals.ts',
output: [
{
file: 'dist/web-vitals.js',
sourcemap: true,
format: 'iife',
name: 'posthog',
},
],
plugins: [...plugins],
},
{
input: 'src/loader-exception-autocapture.ts',
output: [
Expand Down
92 changes: 72 additions & 20 deletions src/__tests__/extensions/replay/sessionrecording.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
SESSION_RECORDING_CANVAS_RECORDING,
SESSION_RECORDING_ENABLED_SERVER_SIDE,
SESSION_RECORDING_IS_SAMPLED,
SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE,
SESSION_RECORDING_SAMPLE_RATE,
} from '../../../constants'
import { SessionIdManager } from '../../../sessionid'
Expand All @@ -15,7 +16,13 @@ import {
META_EVENT_TYPE,
} from '../../../extensions/replay/sessionrecording-utils'
import { PostHog } from '../../../posthog-core'
import { DecideResponse, PostHogConfig, Property, SessionIdChangedCallback } from '../../../types'
import {
DecideResponse,
PerformanceCaptureConfig,
PostHogConfig,
Property,
SessionIdChangedCallback,
} from '../../../types'
import { uuidv7 } from '../../../uuidv7'
import {
RECORDING_IDLE_ACTIVITY_TIMEOUT_MS,
Expand Down Expand Up @@ -222,26 +229,71 @@ describe('SessionRecording', () => {
})

describe('isConsoleLogCaptureEnabled', () => {
it('uses client side setting when set to false', () => {
posthog.persistence?.register({ [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: true })
posthog.config.enable_recording_console_log = false
expect(sessionRecording['isConsoleLogCaptureEnabled']).toBe(false)
})

it('uses client side setting when set to true', () => {
posthog.persistence?.register({ [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: false })
posthog.config.enable_recording_console_log = true
expect(sessionRecording['isConsoleLogCaptureEnabled']).toBe(true)
})

it('uses server side setting if client side setting is not set', () => {
posthog.config.enable_recording_console_log = undefined
posthog.persistence?.register({ [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: false })
expect(sessionRecording['isConsoleLogCaptureEnabled']).toBe(false)
it.each([
['enabled when both enabled', true, true, true],
['uses client side setting when set to false', true, false, false],
['uses client side setting when set to true', false, true, true],
['disabled when both disabled', false, false, false],
['uses client side setting (disabled) if server side setting is not set', undefined, false, false],
['uses client side setting (enabled) if server side setting is not set', undefined, true, true],
['is disabled when nothing is set', undefined, undefined, false],
['uses server side setting (disabled) if client side setting is not set', undefined, false, false],
['uses server side setting (enabled) if client side setting is not set', undefined, true, true],
])(
'%s',
(_name: string, serverSide: boolean | undefined, clientSide: boolean | undefined, expected: boolean) => {
posthog.persistence?.register({ [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: serverSide })
posthog.config.enable_recording_console_log = clientSide
expect(sessionRecording['isConsoleLogCaptureEnabled']).toBe(expected)
}
)
})

posthog.persistence?.register({ [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: true })
expect(sessionRecording['isConsoleLogCaptureEnabled']).toBe(true)
})
describe('network timing capture config', () => {
it.each([
['enabled when both enabled', true, true, true],
// returns undefined when nothing is enabled
['uses client side setting when set to false - even if remotely enabled', true, false, undefined],
['uses client side setting when set to true', false, true, true],
// returns undefined when nothing is enabled
['disabled when both disabled', false, false, undefined],
// returns undefined when nothing is enabled
['uses client side setting (disabled) if server side setting is not set', undefined, false, undefined],
['uses client side setting (enabled) if server side setting is not set', undefined, true, true],
// returns undefined when nothing is enabled
['is disabled when nothing is set', undefined, undefined, undefined],
// returns undefined when nothing is enabled
['can be disabled when client object config only is set', undefined, { network_timing: false }, undefined],
[
'can be disabled when client object config only is disabled - even if remotely enabled',
true,
{ network_timing: false },
undefined,
],
['can be enabled when client object config only is set', undefined, { network_timing: true }, true],
[
'can be disabled when client object config makes no decision',
undefined,
{ network_timing: undefined },
undefined,
],
['uses server side setting (disabled) if client side setting is not set', false, undefined, undefined],
['uses server side setting (enabled) if client side setting is not set', true, undefined, true],
])(
'%s',
(
_name: string,
serverSide: boolean | undefined,
clientSide: boolean | PerformanceCaptureConfig | undefined,
expected: boolean | undefined
) => {
posthog.persistence?.register({
[SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE]: { capturePerformance: serverSide },
})
posthog.config.capture_performance = clientSide
expect(sessionRecording['networkPayloadCapture']?.recordPerformance).toBe(expected)
}
)
})

describe('startIfEnabledOrStop', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/posthog-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe('posthog core', () => {
onCapture.mockClear()
;(console.error as any).mockClear()
posthog.capture(eventName, properties)
expect(onCapture).toHaveBeenCalledTimes(0)
expect(onCapture.mock.calls).toEqual([])
expect(console.error).toHaveBeenCalledTimes(1)
expect(console.error).toHaveBeenCalledWith(
'[PostHog.js]',
Expand Down
172 changes: 172 additions & 0 deletions src/__tests__/web-vitals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { createPosthogInstance } from './helpers/posthog-instance'
import { uuidv7 } from '../uuidv7'
import { PostHog } from '../posthog-core'
import { DecideResponse } from '../types'
import { assignableWindow } from '../utils/globals'
import { FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS } from '../extensions/web-vitals'

jest.mock('../utils/logger')
jest.useFakeTimers()

describe('web vitals', () => {
let posthog: PostHog
let onCapture = jest.fn()
let onLCPCallback: ((metric: Record<string, any>) => void) | undefined = undefined
let onCLSCallback: ((metric: Record<string, any>) => void) | undefined = undefined
let onFCPCallback: ((metric: Record<string, any>) => void) | undefined = undefined
let onINPCallback: ((metric: Record<string, any>) => void) | undefined = undefined

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' })
onFCPCallback?.({ name: 'FCP', value: 123.45, extra: 'property' })
onINPCallback?.({ name: 'INP', value: 123.45, extra: 'property' })
}

const expectedEmittedWebVitals = (name: string) => ({
$current_url: 'http://localhost/',
$session_id: expect.any(String),
$window_id: expect.any(String),
timestamp: expect.any(Number),
name: name,
value: 123.45,
extra: 'property',
})

describe('the behaviour', () => {
beforeEach(async () => {
// 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
},
}

onCapture = jest.fn()
posthog = await createPosthogInstance(uuidv7(), {
_onCapture: onCapture,
capture_performance: { web_vitals: true },
})
})

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,
},
},
])
})

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

expect(onCapture).toBeCalledTimes(0)

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,
},
},
])
})
})

describe('afterDecideResponse()', () => {
beforeEach(async () => {
// 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
},
}

onCapture = jest.fn()
posthog = await createPosthogInstance(uuidv7(), {
_onCapture: onCapture,
})
})

it('should not be enabled before the decide response', () => {
expect(posthog.webVitalsAutocapture!.isEnabled).toBe(false)
})

it('should be enabled if client config option is enabled', () => {
posthog.config.capture_performance = { web_vitals: true }
expect(posthog.webVitalsAutocapture!.isEnabled).toBe(true)
})

it.each([
// Client not defined
[undefined, false, false],
[undefined, true, true],
[undefined, false, false],
// Client false
[false, false, false],
[false, true, false],

// Client true
[true, false, true],
[true, true, true],
])(
'when client side config is %p and remote opt in is %p - web vitals enabled should be %p',
(clientSideOptIn, serverSideOptIn, expected) => {
posthog.config.capture_performance = { web_vitals: clientSideOptIn }
posthog.webVitalsAutocapture!.afterDecideResponse({
capturePerformance: { web_vitals: serverSideOptIn },
} as DecideResponse)
expect(posthog.webVitalsAutocapture!.isEnabled).toBe(expected)
}
)
})
})
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const AUTOCAPTURE_DISABLED_SERVER_SIDE = '$autocapture_disabled_server_si
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 = '$exception_capture_endpoint'
export const WEB_VITALS_ENABLED_SERVER_SIDE = '$web_vitals_enabled_server_side'
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
5 changes: 1 addition & 4 deletions src/decide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@ import { logger } from './utils/logger'
import { document, assignableWindow } from './utils/globals'

export class Decide {
instance: PostHog

constructor(instance: PostHog) {
this.instance = instance
constructor(private readonly instance: PostHog) {
// don't need to wait for `decide` to return if flags were provided on initialisation
this.instance.decideEndpointWasHit = this.instance._hasBootstrappedFeatureFlags()
}
Expand Down
Loading

0 comments on commit d08d318

Please sign in to comment.