Skip to content

Commit a076147

Browse files
committed
feat: lazy load autocapture for fater start and since not everyone has it turned on
1 parent 470810c commit a076147

File tree

15 files changed

+1377
-240
lines changed

15 files changed

+1377
-240
lines changed

SDK_PERFORMANCE_PATTERNS_REPORT.md

Lines changed: 651 additions & 0 deletions
Large diffs are not rendered by default.

packages/browser/playwright/mocked/utils/posthog-playwright-test-base.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const lazyLoadedJSFiles = [
1313
'tracing-headers',
1414
'web-vitals',
1515
'dead-clicks-autocapture',
16+
'autocapture',
1617
]
1718

1819
export type WindowWithPostHog = typeof globalThis & {

packages/browser/rollup.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ const plugins = (es5, noExternal) => [
177177
// set as part of lazy-loading (doesn't start with _ BUT be abundantly cautious)
178178
'loadExternalDependency',
179179

180+
// called across main bundle / lazy bundle boundary for autocapture
181+
'_captureEvent',
182+
180183
// part of the public API (none start with _ so are not mangled anyway BUT be abundantly cautious)
181184
'capture',
182185
'identify',

packages/browser/src/__tests__/autocapture.test.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@ import { shouldCaptureDomEvent } from '../autocapture-utils'
1212
import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from '../constants'
1313
import { AutocaptureConfig, FlagsResponse, PostHogConfig, RageclickConfig } from '../types'
1414
import { PostHog } from '../posthog-core'
15-
import { window } from '../utils/globals'
15+
import { assignableWindow, window } from '../utils/globals'
1616
import { createPosthogInstance } from './helpers/posthog-instance'
1717
import { uuidv7 } from '../uuidv7'
1818
import { isUndefined } from '@posthog/core'
1919

20+
// Import the lazy-loaded implementation and register it on the window
21+
// This simulates what happens when the external script is loaded
22+
import '../entrypoints/autocapture'
23+
2024
// JS DOM doesn't have ClipboardEvent, so we need to mock it
2125
// see https://github.com/jsdom/jsdom/issues/1568
2226
class MockClipboardEvent extends Event implements ClipboardEvent {
@@ -76,6 +80,15 @@ describe('Autocapture system', () => {
7680
value: new URL('https://example.com'),
7781
})
7882

83+
// Mock loadExternalDependency to call the callback immediately
84+
// The initAutocapture function is already registered via the import above
85+
assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {}
86+
assignableWindow.__PosthogExtensions__.loadExternalDependency = jest
87+
.fn()
88+
.mockImplementation((_ph: PostHog, _name: string, cb: (err?: Error) => void) => {
89+
cb()
90+
})
91+
7992
beforeSendMock = jest.fn().mockImplementation((...args) => args)
8093

8194
posthog = await createPosthogInstance(uuidv7(), {
@@ -1181,8 +1194,6 @@ describe('Autocapture system', () => {
11811194
describe('afterFlagsResponse()', () => {
11821195
beforeEach(() => {
11831196
document.title = 'test page'
1184-
1185-
jest.spyOn(autocapture, '_addDomEventHandlers')
11861197
})
11871198

11881199
it('should not be enabled before the flags response', () => {
@@ -1222,32 +1233,34 @@ describe('Autocapture system', () => {
12221233
}
12231234
)
12241235

1225-
it('should call _addDomEventHandlders if autocapture is true in client config', () => {
1236+
it('should initialize handlers if autocapture is true in client config', () => {
12261237
posthog.config.autocapture = true
12271238
autocapture.onRemoteConfig({} as FlagsResponse)
1228-
expect(autocapture['_addDomEventHandlers']).toHaveBeenCalled()
1239+
expect(autocapture['_initialized']).toBe(true)
12291240
})
12301241

1231-
it('should not call _addDomEventHandlders if autocapture is opted out in server config', () => {
1242+
it('should initialize handlers even if autocapture is opted out in server config', () => {
12321243
autocapture.onRemoteConfig({ autocapture_opt_out: true } as FlagsResponse)
1233-
expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled()
1244+
expect(autocapture['_initialized']).toBe(true)
12341245
})
12351246

1236-
it('should not call _addDomEventHandlders if autocapture is disabled in client config', () => {
1237-
expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled()
1247+
it('should not initialize handlers if autocapture is disabled in client config', () => {
1248+
autocapture['_initialized'] = false
12381249
posthog.config.autocapture = false
12391250

12401251
autocapture.onRemoteConfig({} as FlagsResponse)
12411252

1242-
expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled()
1253+
expect(autocapture['_initialized']).toBe(false)
12431254
})
12441255

1245-
it('should NOT call _addDomEventHandlders when the token has already been initialized', () => {
1246-
autocapture.onRemoteConfig({} as FlagsResponse)
1247-
expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1)
1256+
it('should only initialize handlers once', () => {
1257+
const spy = jest.spyOn(autocapture as any, '_addDomEventHandlers')
1258+
autocapture['_initialized'] = false
12481259

12491260
autocapture.onRemoteConfig({} as FlagsResponse)
1250-
expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1)
1261+
autocapture.onRemoteConfig({} as FlagsResponse)
1262+
1263+
expect(spy).toHaveBeenCalledTimes(1)
12511264
})
12521265
})
12531266

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { PostHog } from '../../posthog-core'
2+
import { assignableWindow, document } from '../../utils/globals'
3+
import { Autocapture } from '../../extensions/autocapture'
4+
import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from '../../constants'
5+
6+
describe('Autocapture wrapper', () => {
7+
let mockCaptureEvent: jest.Mock
8+
let loadCallback: ((err?: Error) => void) | null = null
9+
10+
const createMockInstance = (overrides: Partial<PostHog> = {}): PostHog => {
11+
return {
12+
config: {
13+
autocapture: true,
14+
...overrides.config,
15+
},
16+
persistence: {
17+
props: {
18+
[AUTOCAPTURE_DISABLED_SERVER_SIDE]: false,
19+
},
20+
register: jest.fn(),
21+
},
22+
_shouldDisableFlags: () => false,
23+
capture: jest.fn(),
24+
...overrides,
25+
} as unknown as PostHog
26+
}
27+
28+
beforeEach(() => {
29+
mockCaptureEvent = jest.fn()
30+
loadCallback = null
31+
32+
assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {}
33+
// Don't set initAutocapture initially - simulate script not loaded yet
34+
assignableWindow.__PosthogExtensions__.initAutocapture = undefined
35+
36+
// Mock loadExternalDependency to capture the callback instead of calling it immediately
37+
assignableWindow.__PosthogExtensions__.loadExternalDependency = jest
38+
.fn()
39+
.mockImplementation((_ph: PostHog, _name: string, cb: (err?: Error) => void) => {
40+
loadCallback = cb
41+
})
42+
})
43+
44+
const completeScriptLoad = () => {
45+
// Simulate the script loading and registering initAutocapture
46+
assignableWindow.__PosthogExtensions__.initAutocapture = jest.fn().mockReturnValue({
47+
_captureEvent: mockCaptureEvent,
48+
setElementSelectors: jest.fn(),
49+
getElementSelectors: jest.fn(),
50+
})
51+
loadCallback?.()
52+
}
53+
54+
afterEach(() => {
55+
document.getElementsByTagName('html')[0].innerHTML = ''
56+
})
57+
58+
describe('event queuing', () => {
59+
it('queues events before lazy load completes', () => {
60+
const instance = createMockInstance()
61+
const autocapture = new Autocapture(instance)
62+
63+
// Simulate server enabling autocapture
64+
autocapture.onRemoteConfig({ autocapture_opt_out: false } as any)
65+
66+
// Access the private queue for testing
67+
expect((autocapture as any)._eventQueue).toHaveLength(0)
68+
69+
// Simulate click events before lazy load completes
70+
const button = document.createElement('button')
71+
document.body.appendChild(button)
72+
73+
const clickEvent = new MouseEvent('click', { bubbles: true })
74+
;(autocapture as any)._captureEvent(clickEvent)
75+
76+
// Events should be queued
77+
expect((autocapture as any)._eventQueue).toHaveLength(1)
78+
expect((autocapture as any)._eventQueue[0].event).toBe(clickEvent)
79+
80+
// Lazy-loaded implementation should not have been called yet
81+
expect(mockCaptureEvent).not.toHaveBeenCalled()
82+
})
83+
84+
it('processes queued events when lazy load completes', () => {
85+
const instance = createMockInstance()
86+
const autocapture = new Autocapture(instance)
87+
88+
autocapture.onRemoteConfig({ autocapture_opt_out: false } as any)
89+
90+
// Queue some events
91+
const event1 = new MouseEvent('click', { bubbles: true })
92+
const event2 = new MouseEvent('click', { bubbles: true })
93+
;(autocapture as any)._captureEvent(event1)
94+
;(autocapture as any)._captureEvent(event2)
95+
96+
expect((autocapture as any)._eventQueue).toHaveLength(2)
97+
98+
// Complete the lazy load
99+
completeScriptLoad()
100+
101+
// Queue should be empty now
102+
expect((autocapture as any)._eventQueue).toHaveLength(0)
103+
104+
// Events should have been processed with correct timestamps
105+
expect(mockCaptureEvent).toHaveBeenCalledTimes(2)
106+
expect(mockCaptureEvent).toHaveBeenNthCalledWith(1, event1, undefined, expect.any(Date))
107+
expect(mockCaptureEvent).toHaveBeenNthCalledWith(2, event2, undefined, expect.any(Date))
108+
})
109+
110+
it('limits queue size to prevent unbounded growth', () => {
111+
const instance = createMockInstance()
112+
const autocapture = new Autocapture(instance)
113+
114+
autocapture.onRemoteConfig({ autocapture_opt_out: false } as any)
115+
116+
// Try to queue more than MAX_QUEUED_EVENTS (1000)
117+
for (let i = 0; i < 1100; i++) {
118+
const event = new MouseEvent('click', { bubbles: true })
119+
;(autocapture as any)._captureEvent(event)
120+
}
121+
122+
// Queue should be capped at 1000
123+
expect((autocapture as any)._eventQueue).toHaveLength(1000)
124+
})
125+
126+
it('passes events directly to lazy implementation after load', () => {
127+
const instance = createMockInstance()
128+
const autocapture = new Autocapture(instance)
129+
130+
autocapture.onRemoteConfig({ autocapture_opt_out: false } as any)
131+
132+
// Complete lazy load first
133+
completeScriptLoad()
134+
135+
// Now capture an event
136+
const event = new MouseEvent('click', { bubbles: true })
137+
;(autocapture as any)._captureEvent(event)
138+
139+
// Should go directly to lazy implementation, not queue
140+
expect((autocapture as any)._eventQueue).toHaveLength(0)
141+
expect(mockCaptureEvent).toHaveBeenCalledWith(event, undefined)
142+
})
143+
144+
it('does not queue events if autocapture is disabled', () => {
145+
const instance = createMockInstance({
146+
config: { autocapture: false } as any,
147+
})
148+
const autocapture = new Autocapture(instance)
149+
150+
const event = new MouseEvent('click', { bubbles: true })
151+
;(autocapture as any)._captureEvent(event)
152+
153+
expect((autocapture as any)._eventQueue).toHaveLength(0)
154+
expect(mockCaptureEvent).not.toHaveBeenCalled()
155+
})
156+
})
157+
})

packages/browser/src/autocapture-utils.ts

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { logger } from './utils/logger'
66
import { window } from './utils/globals'
77
import { isDocumentFragment, isElementNode, isTag, isTextNode } from './utils/element-utils'
88
import { includes, trim } from '@posthog/core'
9+
import { getEventTarget, getParentElement, autocaptureCompatibleElements } from './utils/dom-event-utils'
10+
11+
// Re-export shared utilities for backwards compatibility
12+
export { getEventTarget, getParentElement, autocaptureCompatibleElements }
913

1014
export function splitClassString(s: string): string[] {
1115
return s ? trim(s).split(/\s+/) : []
@@ -84,20 +88,6 @@ export function getSafeText(el: Element): string {
8488
return trim(elText)
8589
}
8690

87-
export function getEventTarget(e: Event): Element | null {
88-
// https://developer.mozilla.org/en-US/docs/Web/API/Event/target#Compatibility_notes
89-
if (isUndefined(e.target)) {
90-
return (e.srcElement as Element) || null
91-
} else {
92-
if ((e.target as HTMLElement)?.shadowRoot) {
93-
return (e.composedPath()[0] as Element) || null
94-
}
95-
return (e.target as Element) || null
96-
}
97-
}
98-
99-
export const autocaptureCompatibleElements = ['a', 'button', 'form', 'input', 'select', 'textarea', 'label']
100-
10191
/*
10292
if there is no config, then all elements are allowed
10393
if there is a config, and there is an allow list, then only elements in the allow list are allowed
@@ -145,12 +135,6 @@ function checkIfElementsMatchCSSSelector(elements: Element[], selectorList: stri
145135
return false
146136
}
147137

148-
export function getParentElement(curEl: Element): Element | false {
149-
const parentNode = curEl.parentNode
150-
if (!parentNode || !isElementNode(parentNode)) return false
151-
return parentNode
152-
}
153-
154138
const DEFAULT_CONTENT_IGNORELIST = ['next', 'previous', 'prev', '>', '<']
155139
const MAX_CONTENT_IGNORELIST_ENTRIES = 10
156140

0 commit comments

Comments
 (0)