+
{children}
)
}
+
+function VisibilityAndClickTrackers({
+ flag,
+ children,
+ trackInteraction,
+ trackView,
+ options,
+ ...props
+}: {
+ flag: string
+ children: React.ReactNode
+ trackInteraction: boolean
+ trackView: boolean
+ options?: IntersectionObserverInit
+}): JSX.Element {
+ const clickTrackedRef = useRef(false)
+ const visibilityTrackedRef = useRef(false)
+ const posthog = usePostHog()
+
+ const cachedOnClick = useCallback(() => {
+ if (!clickTrackedRef.current && trackInteraction) {
+ captureFeatureInteraction(flag, posthog)
+ clickTrackedRef.current = true
+ }
+ }, [flag, posthog, trackInteraction])
+
+ const onIntersect = (entry: IntersectionObserverEntry) => {
+ if (!visibilityTrackedRef.current && entry.isIntersecting) {
+ captureFeatureView(flag, posthog)
+ visibilityTrackedRef.current = true
+ }
+ }
+
+ const trackedChildren = Children.map(children, (child: ReactNode) => {
+ return (
+
+ {child}
+
+ )
+ })
+
+ return <>{trackedChildren}>
+}
diff --git a/react/src/components/__tests__/PostHogFeature.test.jsx b/react/src/components/__tests__/PostHogFeature.test.jsx
index ba6ad0369..8bc27afbc 100644
--- a/react/src/components/__tests__/PostHogFeature.test.jsx
+++ b/react/src/components/__tests__/PostHogFeature.test.jsx
@@ -1,7 +1,7 @@
import * as React from 'react'
-import { useState } from 'react';
+import { useState } from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
-import { PostHogContext, PostHogProvider } from '../../context'
+import { PostHogProvider } from '../../context'
import { PostHogFeature } from '../'
import '@testing-library/jest-dom'
@@ -89,8 +89,34 @@ describe('PostHogFeature component', () => {
expect(given.posthog.capture).toHaveBeenCalledTimes(1)
})
+ it('should track an interaction with each child node of the feature component', () => {
+ given(
+ 'render',
+ () => () =>
+ render(
+
+
+ Hello
+ World!
+
+
+ )
+ )
+ given.render()
+
+ fireEvent.click(screen.getByTestId('helloDiv'))
+ fireEvent.click(screen.getByTestId('helloDiv'))
+ fireEvent.click(screen.getByTestId('worldDiv'))
+ fireEvent.click(screen.getByTestId('worldDiv'))
+ fireEvent.click(screen.getByTestId('worldDiv'))
+ expect(given.posthog.capture).toHaveBeenCalledWith('$feature_interaction', {
+ feature_flag: 'test',
+ $set: { '$feature_interaction/test': true },
+ })
+ expect(given.posthog.capture).toHaveBeenCalledTimes(1)
+ })
+
it('should not fire events when interaction is disabled', () => {
-
given(
'render',
() => () =>
@@ -114,14 +140,24 @@ describe('PostHogFeature component', () => {
})
it('should fire events when interaction is disabled but re-enabled after', () => {
-
const DynamicUpdateComponent = () => {
const [trackInteraction, setTrackInteraction] = useState(false)
return (
<>
-
{setTrackInteraction(true)}}>Click me
-
+ {
+ setTrackInteraction(true)
+ }}
+ >
+ Click me
+
+
Hello
>
diff --git a/react/src/utils/type-utils.ts b/react/src/utils/type-utils.ts
new file mode 100644
index 000000000..a61194526
--- /dev/null
+++ b/react/src/utils/type-utils.ts
@@ -0,0 +1,14 @@
+// from a comment on http://dbj.org/dbj/?p=286
+// fails on only one very rare and deliberate custom object:
+// let bomb = { toString : undefined, valueOf: function(o) { return "function BOMBA!"; }};
+export const isFunction = function (f: any): f is (...args: any[]) => any {
+ // eslint-disable-next-line posthog-js/no-direct-function-check
+ return typeof f === 'function'
+}
+export const isUndefined = function (x: unknown): x is undefined {
+ return x === void 0
+}
+export const isNull = function (x: unknown): x is null {
+ // eslint-disable-next-line posthog-js/no-direct-null-check
+ return x === null
+}
diff --git a/rollup.config.js b/rollup.config.js
index 648cdd8bb..f947d51c8 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -17,14 +17,33 @@ const plugins = (es5) => [
babel({
extensions: ['.js', '.jsx', '.ts', '.tsx'],
babelHelpers: 'bundled',
- plugins: ['@babel/plugin-transform-nullish-coalescing-operator'],
+ plugins: [
+ '@babel/plugin-transform-nullish-coalescing-operator',
+ // Explicitly included so we transform 1 ** 2 to Math.pow(1, 2) for ES6 compatability
+ '@babel/plugin-transform-exponentiation-operator',
+ ],
presets: [
[
'@babel/preset-env',
{
targets: es5
- ? '>0.5%, last 2 versions, Firefox ESR, not dead, IE 11'
- : '>0.5%, last 2 versions, Firefox ESR, not dead',
+ ? [
+ '> 0.5%, last 2 versions, Firefox ESR, not dead',
+ 'chrome > 62',
+ 'firefox > 59',
+ 'ios_saf >= 6.1',
+ 'opera > 50',
+ 'safari > 12',
+ 'IE 11',
+ ]
+ : [
+ '> 0.5%, last 2 versions, Firefox ESR, not dead',
+ 'chrome > 62',
+ 'firefox > 59',
+ 'ios_saf >= 10.3',
+ 'opera > 50',
+ 'safari > 12',
+ ],
},
],
],
diff --git a/src/__tests__/autocapture.test.ts b/src/__tests__/autocapture.test.ts
index 25d420fa2..1cf4ebb2d 100644
--- a/src/__tests__/autocapture.test.ts
+++ b/src/__tests__/autocapture.test.ts
@@ -63,7 +63,7 @@ describe('Autocapture system', () => {
let autocapture: Autocapture
let posthog: PostHog
- let captureMock: jest.Mock
+ let beforeSendMock: jest.Mock
beforeEach(async () => {
jest.spyOn(window!.console, 'log').mockImplementation()
@@ -76,13 +76,13 @@ describe('Autocapture system', () => {
value: new URL('https://example.com'),
})
- captureMock = jest.fn()
+ beforeSendMock = jest.fn().mockImplementation((...args) => args)
posthog = await createPosthogInstance(uuidv7(), {
api_host: 'https://test.com',
token: 'testtoken',
autocapture: true,
- _onCapture: captureMock,
+ before_send: beforeSendMock,
})
if (isUndefined(posthog.autocapture)) {
@@ -400,8 +400,7 @@ describe('Autocapture system', () => {
autocapture['_captureEvent'](fakeEvent)
autocapture['_captureEvent'](fakeEvent)
- expect(captureMock).toHaveBeenCalledTimes(4)
- expect(captureMock.mock.calls.map((args) => args[0])).toEqual([
+ expect(beforeSendMock.mock.calls.map((args) => args[0].event)).toEqual([
'$autocapture',
'$autocapture',
'$rageclick',
@@ -428,10 +427,11 @@ describe('Autocapture system', () => {
autocapture['_captureEvent'](fakeEvent, '$copy_autocapture')
- expect(captureMock).toHaveBeenCalledTimes(1)
- expect(captureMock.mock.calls[0][0]).toEqual('$copy_autocapture')
- expect(captureMock.mock.calls[0][1].properties).toHaveProperty('$selected_content', 'copy this test')
- expect(captureMock.mock.calls[0][1].properties).toHaveProperty('$copy_type', 'copy')
+ expect(beforeSendMock).toHaveBeenCalledTimes(1)
+ const mockCall = beforeSendMock.mock.calls[0][0]
+ expect(mockCall.event).toEqual('$copy_autocapture')
+ expect(mockCall.properties).toHaveProperty('$selected_content', 'copy this test')
+ expect(mockCall.properties).toHaveProperty('$copy_type', 'copy')
})
it('should capture cut', () => {
@@ -443,11 +443,11 @@ describe('Autocapture system', () => {
autocapture['_captureEvent'](fakeEvent, '$copy_autocapture')
- const spyArgs = captureMock.mock.calls
+ const spyArgs = beforeSendMock.mock.calls
expect(spyArgs.length).toBe(1)
- expect(spyArgs[0][0]).toEqual('$copy_autocapture')
- expect(spyArgs[0][1].properties).toHaveProperty('$selected_content', 'cut this test')
- expect(spyArgs[0][1].properties).toHaveProperty('$copy_type', 'cut')
+ expect(spyArgs[0][0].event).toEqual('$copy_autocapture')
+ expect(spyArgs[0][0].properties).toHaveProperty('$selected_content', 'cut this test')
+ expect(spyArgs[0][0].properties).toHaveProperty('$copy_type', 'cut')
})
it('ignores empty selection', () => {
@@ -459,7 +459,7 @@ describe('Autocapture system', () => {
autocapture['_captureEvent'](fakeEvent, '$copy_autocapture')
- const spyArgs = captureMock.mock.calls
+ const spyArgs = beforeSendMock.mock.calls
expect(spyArgs.length).toBe(0)
})
@@ -473,7 +473,7 @@ describe('Autocapture system', () => {
autocapture['_captureEvent'](fakeEvent, '$copy_autocapture')
- const spyArgs = captureMock.mock.calls
+ const spyArgs = beforeSendMock.mock.calls
expect(spyArgs.length).toBe(0)
})
})
@@ -495,7 +495,7 @@ describe('Autocapture system', () => {
Object.setPrototypeOf(fakeEvent, MouseEvent.prototype)
autocapture['_captureEvent'](fakeEvent)
- const captureProperties = captureMock.mock.calls[0][1].properties
+ const captureProperties = beforeSendMock.mock.calls[0][0].properties
expect(captureProperties).toHaveProperty('target-augment', 'the target')
expect(captureProperties).toHaveProperty('parent-augment', 'the parent')
})
@@ -517,10 +517,10 @@ describe('Autocapture system', () => {
document.body.appendChild(eventElement2)
document.body.appendChild(propertyElement)
- expect(captureMock).toHaveBeenCalledTimes(0)
+ expect(beforeSendMock).toHaveBeenCalledTimes(0)
simulateClick(eventElement1)
simulateClick(eventElement2)
- expect(captureMock).toHaveBeenCalledTimes(0)
+ expect(beforeSendMock).toHaveBeenCalledTimes(0)
})
it('should not capture events when config returns true but server setting is disabled', () => {
@@ -531,9 +531,9 @@ describe('Autocapture system', () => {
const eventElement = document.createElement('a')
document.body.appendChild(eventElement)
- expect(captureMock).toHaveBeenCalledTimes(0)
+ expect(beforeSendMock).toHaveBeenCalledTimes(0)
simulateClick(eventElement)
- expect(captureMock).toHaveBeenCalledTimes(0)
+ expect(beforeSendMock).toHaveBeenCalledTimes(0)
})
it('includes necessary metadata as properties when capturing an event', () => {
@@ -550,10 +550,10 @@ describe('Autocapture system', () => {
target: elTarget,
})
autocapture['_captureEvent'](e)
- expect(captureMock).toHaveBeenCalledTimes(1)
- const captureArgs = captureMock.mock.calls[0]
- const event = captureArgs[0]
- const props = captureArgs[1].properties
+ expect(beforeSendMock).toHaveBeenCalledTimes(1)
+ const captureArgs = beforeSendMock.mock.calls[0]
+ const event = captureArgs[0].event
+ const props = captureArgs[0].properties
expect(event).toBe('$autocapture')
expect(props['$event_type']).toBe('click')
expect(props['$elements'][0]).toHaveProperty('attr__href', 'https://test.com')
@@ -579,9 +579,9 @@ describe('Autocapture system', () => {
target: elTarget,
})
autocapture['_captureEvent'](e)
- expect(captureMock).toHaveBeenCalledTimes(1)
- const captureArgs = captureMock.mock.calls[0]
- const props = captureArgs[1].properties
+ expect(beforeSendMock).toHaveBeenCalledTimes(1)
+ const captureArgs = beforeSendMock.mock.calls[0]
+ const props = captureArgs[0].properties
expect(longString).toBe('prop'.repeat(400))
expect(props['$elements'][0]).toHaveProperty('attr__data-props', 'prop'.repeat(256) + '...')
})
@@ -606,7 +606,7 @@ describe('Autocapture system', () => {
})
)
- const props = captureMock.mock.calls[0][1].properties
+ const props = beforeSendMock.mock.calls[0][0].properties
expect(props['$element_selectors']).toContain('#primary_button')
expect(props['$elements'][0]).toHaveProperty('attr__href', 'https://test.com')
expect(props['$external_click_url']).toEqual('https://test.com')
@@ -624,7 +624,7 @@ describe('Autocapture system', () => {
target: elTarget,
})
)
- const props = captureMock.mock.calls[0][1].properties
+ const props = beforeSendMock.mock.calls[0][0].properties
expect(props['$elements'][0]).toHaveProperty('attr__href', 'https://test.com')
expect(props['$external_click_url']).toEqual('https://test.com')
})
@@ -642,7 +642,7 @@ describe('Autocapture system', () => {
target: elTarget,
})
)
- const props = captureMock.mock.calls[0][1].properties
+ const props = beforeSendMock.mock.calls[0][0].properties
expect(props['$elements'][0]).toHaveProperty('attr__href', 'https://www.example.com/link')
expect(props['$external_click_url']).toBeUndefined()
})
@@ -659,7 +659,7 @@ describe('Autocapture system', () => {
target: elTarget,
})
)
- expect(captureMock.mock.calls[0][1].properties).not.toHaveProperty('attr__href')
+ expect(beforeSendMock.mock.calls[0][0].properties).not.toHaveProperty('attr__href')
})
it('does not capture href attribute values from hidden elements', () => {
@@ -674,7 +674,7 @@ describe('Autocapture system', () => {
target: elTarget,
})
)
- expect(captureMock.mock.calls[0][1].properties['$elements'][0]).not.toHaveProperty('attr__href')
+ expect(beforeSendMock.mock.calls[0][0].properties['$elements'][0]).not.toHaveProperty('attr__href')
})
it('does not capture href attribute values that look like credit card numbers', () => {
@@ -689,7 +689,7 @@ describe('Autocapture system', () => {
target: elTarget,
})
)
- expect(captureMock.mock.calls[0][1].properties['$elements'][0]).not.toHaveProperty('attr__href')
+ expect(beforeSendMock.mock.calls[0][0].properties['$elements'][0]).not.toHaveProperty('attr__href')
})
it('does not capture href attribute values that look like social-security numbers', () => {
@@ -704,7 +704,7 @@ describe('Autocapture system', () => {
target: elTarget,
})
)
- expect(captureMock.mock.calls[0][1].properties['$elements'][0]).not.toHaveProperty('attr__href')
+ expect(beforeSendMock.mock.calls[0][0].properties['$elements'][0]).not.toHaveProperty('attr__href')
})
it('correctly identifies and formats text content', () => {
@@ -745,10 +745,10 @@ describe('Autocapture system', () => {
const e1 = makeMouseEvent({
target: span2,
})
- captureMock.mockClear()
+ beforeSendMock.mockClear()
autocapture['_captureEvent'](e1)
- const props1 = captureMock.mock.calls[0][1].properties
+ const props1 = beforeSendMock.mock.calls[0][0].properties
const text1 =
"Some super duper really long Text with new lines that we'll strip out and also we will want to make this text shorter since it's not likely people really care about text content that's super long and it also takes up more space and bandwidth. Some super d"
expect(props1['$elements'][0]).toHaveProperty('$el_text', text1)
@@ -757,18 +757,18 @@ describe('Autocapture system', () => {
const e2 = makeMouseEvent({
target: span1,
})
- captureMock.mockClear()
+ beforeSendMock.mockClear()
autocapture['_captureEvent'](e2)
- const props2 = captureMock.mock.calls[0][1].properties
+ const props2 = beforeSendMock.mock.calls[0][0].properties
expect(props2['$elements'][0]).toHaveProperty('$el_text', 'Some text')
expect(props2['$el_text']).toEqual('Some text')
const e3 = makeMouseEvent({
target: img2,
})
- captureMock.mockClear()
+ beforeSendMock.mockClear()
autocapture['_captureEvent'](e3)
- const props3 = captureMock.mock.calls[0][1].properties
+ const props3 = beforeSendMock.mock.calls[0][0].properties
expect(props3['$elements'][0]).toHaveProperty('$el_text', '')
expect(props3).not.toHaveProperty('$el_text')
})
@@ -796,7 +796,7 @@ describe('Autocapture system', () => {
target: button1,
})
autocapture['_captureEvent'](e1)
- const props1 = captureMock.mock.calls[0][1].properties
+ const props1 = beforeSendMock.mock.calls[0][0].properties
expect(props1['$elements'][0]).toHaveProperty('$el_text')
expect(props1['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/)
@@ -804,7 +804,7 @@ describe('Autocapture system', () => {
target: button2,
})
autocapture['_captureEvent'](e2)
- const props2 = captureMock.mock.calls[0][1].properties
+ const props2 = beforeSendMock.mock.calls[0][0].properties
expect(props2['$elements'][0]).toHaveProperty('$el_text')
expect(props2['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/)
@@ -812,7 +812,7 @@ describe('Autocapture system', () => {
target: button3,
})
autocapture['_captureEvent'](e3)
- const props3 = captureMock.mock.calls[0][1].properties
+ const props3 = beforeSendMock.mock.calls[0][0].properties
expect(props3['$elements'][0]).toHaveProperty('$el_text')
expect(props3['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/)
})
@@ -823,8 +823,8 @@ describe('Autocapture system', () => {
type: 'submit',
} as unknown as FormDataEvent
autocapture['_captureEvent'](e)
- expect(captureMock).toHaveBeenCalledTimes(1)
- const props = captureMock.mock.calls[0][1].properties
+ expect(beforeSendMock).toHaveBeenCalledTimes(1)
+ const props = beforeSendMock.mock.calls[0][0].properties
expect(props['$event_type']).toBe('submit')
})
@@ -840,8 +840,8 @@ describe('Autocapture system', () => {
target: link,
})
autocapture['_captureEvent'](e)
- expect(captureMock).toHaveBeenCalledTimes(1)
- const props = captureMock.mock.calls[0][1].properties
+ expect(beforeSendMock).toHaveBeenCalledTimes(1)
+ const props = beforeSendMock.mock.calls[0][0].properties
expect(props['$event_type']).toBe('click')
})
@@ -856,8 +856,8 @@ describe('Autocapture system', () => {
composedPath: () => [button, main_el],
})
autocapture['_captureEvent'](e)
- expect(captureMock).toHaveBeenCalledTimes(1)
- const props = captureMock.mock.calls[0][1].properties
+ expect(beforeSendMock).toHaveBeenCalledTimes(1)
+ const props = beforeSendMock.mock.calls[0][0].properties
expect(props['$event_type']).toBe('click')
})
@@ -866,18 +866,18 @@ describe('Autocapture system', () => {
const span = document.createElement('span')
a.appendChild(span)
autocapture['_captureEvent'](makeMouseEvent({ target: a }))
- expect(captureMock).toHaveBeenCalledTimes(1)
+ expect(beforeSendMock).toHaveBeenCalledTimes(1)
autocapture['_captureEvent'](makeMouseEvent({ target: span }))
- expect(captureMock).toHaveBeenCalledTimes(2)
+ expect(beforeSendMock).toHaveBeenCalledTimes(2)
- captureMock.mockClear()
+ beforeSendMock.mockClear()
a.className = 'test1 ph-no-capture test2'
autocapture['_captureEvent'](makeMouseEvent({ target: a }))
- expect(captureMock).toHaveBeenCalledTimes(0)
+ expect(beforeSendMock).toHaveBeenCalledTimes(0)
autocapture['_captureEvent'](makeMouseEvent({ target: span }))
- expect(captureMock).toHaveBeenCalledTimes(0)
+ expect(beforeSendMock).toHaveBeenCalledTimes(0)
})
it('does not capture any element attributes if mask_all_element_attributes is set', () => {
@@ -897,7 +897,7 @@ describe('Autocapture system', () => {
})
autocapture['_captureEvent'](e1)
- const props1 = captureMock.mock.calls[0][1].properties
+ const props1 = beforeSendMock.mock.calls[0][0].properties
expect('attr__formmethod' in props1['$elements'][0]).toEqual(false)
})
@@ -916,7 +916,7 @@ describe('Autocapture system', () => {
})
autocapture['_captureEvent'](e1)
- const props1 = captureMock.mock.calls[0][1].properties
+ const props1 = beforeSendMock.mock.calls[0][0].properties
expect(props1['$elements'][0]).not.toHaveProperty('$el_text')
})
@@ -937,7 +937,7 @@ describe('Autocapture system', () => {
} as DecideResponse)
autocapture['_captureEvent'](e)
- const props1 = captureMock.mock.calls[0][1].properties
+ const props1 = beforeSendMock.mock.calls[0][0].properties
expect(props1['$elements_chain']).toBeDefined()
expect(props1['$elements']).toBeUndefined()
@@ -960,7 +960,7 @@ describe('Autocapture system', () => {
autocapture['_elementsChainAsString'] = true
autocapture['_captureEvent'](e)
- const props1 = captureMock.mock.calls[0][1].properties
+ const props1 = beforeSendMock.mock.calls[0][0].properties
expect(props1['$elements_chain']).toBe(
'a.test-class.test-class2.test-class3.test-class4.test-class5:nth-child="1"nth-of-type="1"href="http://test.com"attr__href="http://test.com"attr__class="test-class test-class2 test-class3 test-class4 test-class5";span:nth-child="1"nth-of-type="1"'
@@ -991,8 +991,8 @@ describe('Autocapture system', () => {
autocapture['_captureEvent'](e)
- expect(captureMock).toHaveBeenCalledTimes(1)
- const props = captureMock.mock.calls[0][1].properties
+ expect(beforeSendMock).toHaveBeenCalledTimes(1)
+ const props = beforeSendMock.mock.calls[0][0].properties
const capturedButton = props['$elements'][1]
expect(capturedButton['tag_name']).toBe('button')
expect(capturedButton['$el_text']).toBe('the button text with more info')
@@ -1011,11 +1011,11 @@ describe('Autocapture system', () => {
document.body.appendChild(button)
simulateClick(button)
simulateClick(button)
- expect(captureMock).toHaveBeenCalledTimes(2)
- expect(captureMock.mock.calls[0][0]).toBe('$autocapture')
- expect(captureMock.mock.calls[0][1].properties['$event_type']).toBe('click')
- expect(captureMock.mock.calls[1][0]).toBe('$autocapture')
- expect(captureMock.mock.calls[1][1].properties['$event_type']).toBe('click')
+ expect(beforeSendMock).toHaveBeenCalledTimes(2)
+ expect(beforeSendMock.mock.calls[0][0].event).toBe('$autocapture')
+ expect(beforeSendMock.mock.calls[0][0].properties['$event_type']).toBe('click')
+ expect(beforeSendMock.mock.calls[1][0].event).toBe('$autocapture')
+ expect(beforeSendMock.mock.calls[1][0].properties['$event_type']).toBe('click')
})
})
diff --git a/src/__tests__/consent.test.ts b/src/__tests__/consent.test.ts
index c9483ac4a..b6f520ee5 100644
--- a/src/__tests__/consent.test.ts
+++ b/src/__tests__/consent.test.ts
@@ -81,15 +81,15 @@ describe('consentManager', () => {
})
describe('opt out event', () => {
- let onCapture = jest.fn()
+ let beforeSendMock = jest.fn().mockImplementation((...args) => args)
beforeEach(() => {
- onCapture = jest.fn()
- posthog = createPostHog({ opt_out_capturing_by_default: true, _onCapture: onCapture })
+ beforeSendMock = jest.fn().mockImplementation((e) => e)
+ posthog = createPostHog({ opt_out_capturing_by_default: true, before_send: beforeSendMock })
})
it('should send opt in event if not disabled', () => {
posthog.opt_in_capturing()
- expect(onCapture).toHaveBeenCalledWith('$opt_in', expect.objectContaining({}))
+ expect(beforeSendMock).toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' }))
})
it('should send opt in event with overrides', () => {
@@ -99,9 +99,9 @@ describe('consentManager', () => {
foo: 'bar',
},
})
- expect(onCapture).toHaveBeenCalledWith(
- 'override-opt-in',
+ expect(beforeSendMock).toHaveBeenCalledWith(
expect.objectContaining({
+ event: 'override-opt-in',
properties: expect.objectContaining({
foo: 'bar',
}),
@@ -111,72 +111,72 @@ describe('consentManager', () => {
it('should not send opt in event if false', () => {
posthog.opt_in_capturing({ captureEventName: false })
- expect(onCapture).toHaveBeenCalledTimes(1)
- expect(onCapture).not.toHaveBeenCalledWith('$opt_in')
- expect(onCapture).lastCalledWith('$pageview', expect.anything())
+ expect(beforeSendMock).toHaveBeenCalledTimes(1)
+ expect(beforeSendMock).not.toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' }))
+ expect(beforeSendMock).lastCalledWith(expect.objectContaining({ event: '$pageview' }))
})
it('should not send opt in event if false', () => {
posthog.opt_in_capturing({ captureEventName: false })
- expect(onCapture).toHaveBeenCalledTimes(1)
- expect(onCapture).not.toHaveBeenCalledWith('$opt_in')
- expect(onCapture).lastCalledWith('$pageview', expect.anything())
+ expect(beforeSendMock).toHaveBeenCalledTimes(1)
+ expect(beforeSendMock).not.toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' }))
+ expect(beforeSendMock).lastCalledWith(expect.objectContaining({ event: '$pageview' }))
})
it('should not send $pageview on opt in if capturing is disabled', () => {
posthog = createPostHog({
opt_out_capturing_by_default: true,
- _onCapture: onCapture,
+ before_send: beforeSendMock,
capture_pageview: false,
})
posthog.opt_in_capturing({ captureEventName: false })
- expect(onCapture).toHaveBeenCalledTimes(0)
+ expect(beforeSendMock).toHaveBeenCalledTimes(0)
})
it('should not send $pageview on opt in if is has already been captured', async () => {
posthog = createPostHog({
- _onCapture: onCapture,
+ before_send: beforeSendMock,
})
// Wait for the initial $pageview to be captured
// eslint-disable-next-line compat/compat
await new Promise((r) => setTimeout(r, 10))
- expect(onCapture).toHaveBeenCalledTimes(1)
- expect(onCapture).lastCalledWith('$pageview', expect.anything())
+ expect(beforeSendMock).toHaveBeenCalledTimes(1)
+ expect(beforeSendMock).lastCalledWith(expect.objectContaining({ event: '$pageview' }))
posthog.opt_in_capturing()
- expect(onCapture).toHaveBeenCalledTimes(2)
- expect(onCapture).toHaveBeenCalledWith('$opt_in', expect.anything())
+ expect(beforeSendMock).toHaveBeenCalledTimes(2)
+ expect(beforeSendMock).toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' }))
})
it('should send $pageview on opt in if is has not been captured', async () => {
// Some other tests might call setTimeout after they've passed, so creating a new instance here.
- const onCapture = jest.fn()
- const posthog = createPostHog({ _onCapture: onCapture })
+ const beforeSendMock = jest.fn().mockImplementation((e) => e)
+ const posthog = createPostHog({ before_send: beforeSendMock })
posthog.opt_in_capturing()
- expect(onCapture).toHaveBeenCalledTimes(2)
- expect(onCapture).toHaveBeenCalledWith('$opt_in', expect.anything())
- expect(onCapture).lastCalledWith('$pageview', expect.anything())
+ expect(beforeSendMock).toHaveBeenCalledTimes(2)
+ expect(beforeSendMock).toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' }))
+ expect(beforeSendMock).lastCalledWith(expect.objectContaining({ event: '$pageview' }))
// Wait for the $pageview timeout to be called
// eslint-disable-next-line compat/compat
await new Promise((r) => setTimeout(r, 10))
- expect(onCapture).toHaveBeenCalledTimes(2)
+ expect(beforeSendMock).toHaveBeenCalledTimes(2)
})
it('should not send $pageview on subsequent opt in', async () => {
// Some other tests might call setTimeout after they've passed, so creating a new instance here.
- const onCapture = jest.fn()
- const posthog = createPostHog({ _onCapture: onCapture })
+ const beforeSendMock = jest.fn().mockImplementation((e) => e)
+ const posthog = createPostHog({ before_send: beforeSendMock })
posthog.opt_in_capturing()
- expect(onCapture).toHaveBeenCalledTimes(2)
- expect(onCapture).toHaveBeenCalledWith('$opt_in', expect.anything())
- expect(onCapture).lastCalledWith('$pageview', expect.anything())
+ expect(beforeSendMock).toHaveBeenCalledTimes(2)
+ expect(beforeSendMock).toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' }))
+ expect(beforeSendMock).lastCalledWith(expect.objectContaining({ event: '$pageview' }))
// Wait for the $pageview timeout to be called
// eslint-disable-next-line compat/compat
await new Promise((r) => setTimeout(r, 10))
posthog.opt_in_capturing()
- expect(onCapture).toHaveBeenCalledTimes(3)
- expect(onCapture).not.lastCalledWith('$pageview', expect.anything())
+ expect(beforeSendMock).toHaveBeenCalledTimes(3)
+ expect(beforeSendMock).not.lastCalledWith(expect.objectContaining({ event: '$pageview' }))
})
})
@@ -238,18 +238,18 @@ describe('consentManager', () => {
})
it(`should capture an event recording the opt-in action`, () => {
- const onCapture = jest.fn()
- posthog.on('eventCaptured', onCapture)
+ const beforeSendMock = jest.fn()
+ posthog.on('eventCaptured', beforeSendMock)
posthog.opt_in_capturing()
- expect(onCapture).toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' }))
+ expect(beforeSendMock).toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' }))
- onCapture.mockClear()
+ beforeSendMock.mockClear()
const captureEventName = `єνєηт`
const captureProperties = { '𝖕𝖗𝖔𝖕𝖊𝖗𝖙𝖞': `𝓿𝓪𝓵𝓾𝓮` }
posthog.opt_in_capturing({ captureEventName, captureProperties })
- expect(onCapture).toHaveBeenCalledWith(
+ expect(beforeSendMock).toHaveBeenCalledWith(
expect.objectContaining({
event: captureEventName,
properties: expect.objectContaining(captureProperties),
diff --git a/src/__tests__/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags.test.ts b/src/__tests__/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags.test.ts
new file mode 100644
index 000000000..f1ec7fbae
--- /dev/null
+++ b/src/__tests__/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags.test.ts
@@ -0,0 +1,88 @@
+import { uuidv7 } from '../../uuidv7'
+import { createPosthogInstance } from '../helpers/posthog-instance'
+import { setAllPersonProfilePropertiesAsPersonPropertiesForFlags } from '../../customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags'
+import { STORED_PERSON_PROPERTIES_KEY } from '../../constants'
+
+jest.mock('../../utils/globals', () => {
+ const orig = jest.requireActual('../../utils/globals')
+ const mockURLGetter = jest.fn()
+ const mockReferrerGetter = jest.fn()
+ return {
+ ...orig,
+ mockURLGetter,
+ mockReferrerGetter,
+ userAgent: 'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0',
+ navigator: {
+ vendor: '',
+ },
+ document: {
+ ...orig.document,
+ createElement: (...args: any[]) => orig.document.createElement(...args),
+ get referrer() {
+ return mockReferrerGetter()
+ },
+ get URL() {
+ return mockURLGetter()
+ },
+ },
+ get location() {
+ const url = mockURLGetter()
+ return {
+ href: url,
+ toString: () => url,
+ }
+ },
+ }
+})
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const { mockURLGetter, mockReferrerGetter } = require('../../utils/globals')
+
+describe('setAllPersonPropertiesForFlags', () => {
+ beforeEach(() => {
+ mockReferrerGetter.mockReturnValue('https://referrer.com')
+ mockURLGetter.mockReturnValue('https://example.com?utm_source=foo')
+ })
+
+ it('should called setPersonPropertiesForFlags with all saved properties that are used for person properties', async () => {
+ // arrange
+ const token = uuidv7()
+ const posthog = await createPosthogInstance(token)
+
+ // act
+ setAllPersonProfilePropertiesAsPersonPropertiesForFlags(posthog)
+
+ // assert
+ expect(posthog.persistence?.props[STORED_PERSON_PROPERTIES_KEY]).toMatchInlineSnapshot(`
+ Object {
+ "$browser": "Mobile Safari",
+ "$browser_version": null,
+ "$current_url": "https://example.com?utm_source=foo",
+ "$device_type": "Mobile",
+ "$os": "Android",
+ "$os_version": "4.4.0",
+ "$referrer": "https://referrer.com",
+ "$referring_domain": "referrer.com",
+ "dclid": null,
+ "fbclid": null,
+ "gad_source": null,
+ "gbraid": null,
+ "gclid": null,
+ "gclsrc": null,
+ "igshid": null,
+ "li_fat_id": null,
+ "mc_cid": null,
+ "msclkid": null,
+ "rdt_cid": null,
+ "ttclid": null,
+ "twclid": null,
+ "utm_campaign": null,
+ "utm_content": null,
+ "utm_medium": null,
+ "utm_source": "foo",
+ "utm_term": null,
+ "wbraid": null,
+ }
+ `)
+ })
+})
diff --git a/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts b/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts
index 492bae78b..ad934e2b1 100644
--- a/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts
+++ b/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts
@@ -35,7 +35,7 @@ describe('Exception Observer', () => {
let exceptionObserver: ExceptionObserver
let posthog: PostHog
let sendRequestSpy: jest.SpyInstance
- const mockCapture = jest.fn()
+ const beforeSendMock = jest.fn().mockImplementation((e) => e)
const loadScriptMock = jest.fn()
const addErrorWrappingFlagToWindow = () => {
@@ -51,7 +51,7 @@ describe('Exception Observer', () => {
callback()
})
- posthog = await createPosthogInstance(uuidv7(), { _onCapture: mockCapture })
+ posthog = await createPosthogInstance(uuidv7(), { before_send: beforeSendMock })
assignableWindow.__PosthogExtensions__ = {
loadExternalDependency: loadScriptMock,
}
@@ -91,11 +91,11 @@ describe('Exception Observer', () => {
const error = new Error('test error')
window!.onerror?.call(window, 'message', 'source', 0, 0, error)
- const captureCalls = mockCapture.mock.calls
+ const captureCalls = beforeSendMock.mock.calls
expect(captureCalls.length).toBe(1)
const singleCall = captureCalls[0]
- expect(singleCall[0]).toBe('$exception')
- expect(singleCall[1]).toMatchObject({
+ expect(singleCall[0]).toMatchObject({
+ event: '$exception',
properties: {
$exception_personURL: expect.any(String),
$exception_list: [
@@ -120,11 +120,11 @@ describe('Exception Observer', () => {
})
window!.onunhandledrejection?.call(window!, promiseRejectionEvent)
- const captureCalls = mockCapture.mock.calls
+ const captureCalls = beforeSendMock.mock.calls
expect(captureCalls.length).toBe(1)
const singleCall = captureCalls[0]
- expect(singleCall[0]).toBe('$exception')
- expect(singleCall[1]).toMatchObject({
+ expect(singleCall[0]).toMatchObject({
+ event: '$exception',
properties: {
$exception_personURL: expect.any(String),
$exception_list: [
diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts
index 3b4cadfbf..53199fbf8 100644
--- a/src/__tests__/extensions/replay/sessionrecording.test.ts
+++ b/src/__tests__/extensions/replay/sessionrecording.test.ts
@@ -48,6 +48,7 @@ import {
import Mock = jest.Mock
import { ConsentManager } from '../../../consent'
import { waitFor } from '@testing-library/preact'
+import { SimpleEventEmitter } from '../../../utils/simple-event-emitter'
// Type and source defined here designate a non-user-generated recording event
@@ -185,6 +186,7 @@ describe('SessionRecording', () => {
let onFeatureFlagsCallback: ((flags: string[], variants: Record) => void) | null
let removeCaptureHookMock: Mock
let addCaptureHookMock: Mock
+ let simpleEventEmitter: SimpleEventEmitter
const addRRwebToWindow = () => {
assignableWindow.__PosthogExtensions__.rrweb = {
@@ -239,6 +241,8 @@ describe('SessionRecording', () => {
removeCaptureHookMock = jest.fn()
addCaptureHookMock = jest.fn().mockImplementation(() => removeCaptureHookMock)
+ simpleEventEmitter = new SimpleEventEmitter()
+ // TODO we really need to make this a real posthog instance :cry:
posthog = {
get_property: (property_key: string): Property | undefined => {
return postHogPersistence?.['props'][property_key]
@@ -261,6 +265,10 @@ describe('SessionRecording', () => {
},
} as unknown as ConsentManager,
register_for_session() {},
+ _internalEventEmitter: simpleEventEmitter,
+ on: (event, cb) => {
+ return simpleEventEmitter.on(event, cb)
+ },
} as Partial as PostHog
loadScriptMock.mockImplementation((_ph, _path, callback) => {
@@ -1883,6 +1891,7 @@ describe('SessionRecording', () => {
loadScriptMock.mockImplementation((_ph, _path, callback) => {
callback()
})
+ sessionRecording = new SessionRecording(posthog)
sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } }))
sessionRecording.startIfEnabledOrStop()
@@ -2250,4 +2259,67 @@ describe('SessionRecording', () => {
])
})
})
+
+ describe('Event triggering', () => {
+ beforeEach(() => {
+ sessionRecording.startIfEnabledOrStop()
+ })
+
+ it('flushes buffer and starts when sees event', async () => {
+ sessionRecording.afterDecideResponse(
+ makeDecideResponse({
+ sessionRecording: {
+ endpoint: '/s/',
+ eventTriggers: ['$exception'],
+ },
+ })
+ )
+
+ expect(sessionRecording['status']).toBe('buffering')
+
+ // Emit some events before hitting blocked URL
+ _emit(createIncrementalSnapshot({ data: { source: 1 } }))
+ _emit(createIncrementalSnapshot({ data: { source: 2 } }))
+
+ expect(sessionRecording['buffer'].data).toHaveLength(2)
+
+ simpleEventEmitter.emit('eventCaptured', { event: 'not-$exception' })
+
+ expect(sessionRecording['status']).toBe('buffering')
+
+ simpleEventEmitter.emit('eventCaptured', { event: '$exception' })
+
+ expect(sessionRecording['status']).toBe('active')
+ expect(sessionRecording['buffer'].data).toHaveLength(0)
+ })
+
+ it('starts if sees an event but still waiting for a URL', async () => {
+ sessionRecording.afterDecideResponse(
+ makeDecideResponse({
+ sessionRecording: {
+ endpoint: '/s/',
+ eventTriggers: ['$exception'],
+ urlTriggers: [{ url: 'start-on-me', matching: 'regex' }],
+ },
+ })
+ )
+
+ expect(sessionRecording['status']).toBe('buffering')
+
+ // Emit some events before hitting blocked URL
+ _emit(createIncrementalSnapshot({ data: { source: 1 } }))
+ _emit(createIncrementalSnapshot({ data: { source: 2 } }))
+
+ expect(sessionRecording['buffer'].data).toHaveLength(2)
+
+ simpleEventEmitter.emit('eventCaptured', { event: 'not-$exception' })
+
+ expect(sessionRecording['status']).toBe('buffering')
+
+ simpleEventEmitter.emit('eventCaptured', { event: '$exception' })
+
+ // even though still waiting for URL to trigger
+ expect(sessionRecording['status']).toBe('active')
+ })
+ })
})
diff --git a/src/__tests__/extensions/web-vitals.test.ts b/src/__tests__/extensions/web-vitals.test.ts
index e83ae0494..5cd284a13 100644
--- a/src/__tests__/extensions/web-vitals.test.ts
+++ b/src/__tests__/extensions/web-vitals.test.ts
@@ -10,7 +10,7 @@ jest.useFakeTimers()
describe('web vitals', () => {
let posthog: PostHog
- let onCapture = jest.fn()
+ let beforeSendMock = jest.fn().mockImplementation((e) => e)
let onLCPCallback: ((metric: Record) => void) | undefined = undefined
let onCLSCallback: ((metric: Record) => void) | undefined = undefined
let onFCPCallback: ((metric: Record) => void) | undefined = undefined
@@ -81,9 +81,9 @@ describe('web vitals', () => {
expectedProperties: Record
) => {
beforeEach(async () => {
- onCapture.mockClear()
+ beforeSendMock.mockClear()
posthog = await createPosthogInstance(uuidv7(), {
- _onCapture: onCapture,
+ before_send: beforeSendMock,
capture_performance: { web_vitals: true, web_vitals_allowed_metrics: clientConfig },
// sometimes pageviews sneak in and make asserting on mock capture tricky
capture_pageview: false,
@@ -123,10 +123,9 @@ describe('web vitals', () => {
it('should emit when all allowed metrics are captured', async () => {
emitAllMetrics()
- expect(onCapture).toBeCalledTimes(1)
+ expect(beforeSendMock).toBeCalledTimes(1)
- expect(onCapture.mock.lastCall).toMatchObject([
- '$web_vitals',
+ expect(beforeSendMock.mock.lastCall).toMatchObject([
{
event: '$web_vitals',
properties: expectedProperties,
@@ -137,14 +136,12 @@ describe('web vitals', () => {
it('should emit after 5 seconds even when only 1 to 3 metrics captured', async () => {
onCLSCallback?.({ name: 'CLS', value: 123.45, extra: 'property' })
- expect(onCapture).toBeCalledTimes(0)
+ expect(beforeSendMock).toBeCalledTimes(0)
jest.advanceTimersByTime(DEFAULT_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',
+ expect(beforeSendMock.mock.lastCall).toMatchObject([
{
event: '$web_vitals',
properties: {
@@ -159,12 +156,11 @@ describe('web vitals', () => {
;(posthog.config.capture_performance as PerformanceCaptureConfig).web_vitals_delayed_flush_ms = 1000
onCLSCallback?.({ name: 'CLS', value: 123.45, extra: 'property' })
- expect(onCapture).toBeCalledTimes(0)
+ expect(beforeSendMock).toBeCalledTimes(0)
jest.advanceTimersByTime(1000 + 1)
- expect(onCapture.mock.lastCall).toMatchObject([
- '$web_vitals',
+ expect(beforeSendMock.mock.lastCall).toMatchObject([
{
event: '$web_vitals',
properties: {
@@ -178,22 +174,22 @@ describe('web vitals', () => {
it('should ignore a ridiculous value', async () => {
onCLSCallback?.({ name: 'CLS', value: FIFTEEN_MINUTES_IN_MILLIS, extra: 'property' })
- expect(onCapture).toBeCalledTimes(0)
+ expect(beforeSendMock).toBeCalledTimes(0)
jest.advanceTimersByTime(DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1)
- expect(onCapture.mock.calls).toEqual([])
+ expect(beforeSendMock.mock.calls).toEqual([])
})
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(beforeSendMock).toBeCalledTimes(0)
jest.advanceTimersByTime(DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1)
- expect(onCapture).toBeCalledTimes(1)
+ expect(beforeSendMock).toBeCalledTimes(1)
})
}
)
@@ -217,9 +213,9 @@ describe('web vitals', () => {
},
}
- onCapture = jest.fn()
+ beforeSendMock = jest.fn()
posthog = await createPosthogInstance(uuidv7(), {
- _onCapture: onCapture,
+ before_send: beforeSendMock,
})
})
diff --git a/src/__tests__/heatmaps.test.ts b/src/__tests__/heatmaps.test.ts
index 95a4c4fb5..0f1588d2c 100644
--- a/src/__tests__/heatmaps.test.ts
+++ b/src/__tests__/heatmaps.test.ts
@@ -12,7 +12,7 @@ jest.useFakeTimers()
describe('heatmaps', () => {
let posthog: PostHog
- let onCapture = jest.fn()
+ let beforeSendMock = jest.fn().mockImplementation((e) => e)
const createMockMouseEvent = (props: Partial = {}) =>
({
@@ -23,10 +23,10 @@ describe('heatmaps', () => {
} as unknown as MouseEvent)
beforeEach(async () => {
- onCapture = onCapture.mockClear()
+ beforeSendMock = beforeSendMock.mockClear()
posthog = await createPosthogInstance(uuidv7(), {
- _onCapture: onCapture,
+ before_send: beforeSendMock,
sanitize_properties: (props) => {
// what ever sanitization makes sense
const sanitizeUrl = (url: string) => url.replace(/https?:\/\/[^/]+/g, 'http://replaced')
@@ -61,9 +61,8 @@ describe('heatmaps', () => {
jest.advanceTimersByTime(posthog.heatmaps!.flushIntervalMilliseconds + 1)
- expect(onCapture).toBeCalledTimes(1)
- expect(onCapture.mock.lastCall[0]).toEqual('$$heatmap')
- expect(onCapture.mock.lastCall[1]).toMatchObject({
+ expect(beforeSendMock).toBeCalledTimes(1)
+ expect(beforeSendMock.mock.lastCall[0]).toMatchObject({
event: '$$heatmap',
properties: {
$heatmap_data: {
@@ -85,7 +84,7 @@ describe('heatmaps', () => {
jest.advanceTimersByTime(posthog.heatmaps!.flushIntervalMilliseconds - 1)
- expect(onCapture).toBeCalledTimes(0)
+ expect(beforeSendMock).toBeCalledTimes(0)
expect(posthog.heatmaps!.getAndClearBuffer()).toBeDefined()
})
@@ -96,9 +95,9 @@ describe('heatmaps', () => {
jest.advanceTimersByTime(posthog.heatmaps!.flushIntervalMilliseconds + 1)
- expect(onCapture).toBeCalledTimes(1)
- expect(onCapture.mock.lastCall[0]).toEqual('$$heatmap')
- const heatmapData = onCapture.mock.lastCall[1].properties.$heatmap_data
+ expect(beforeSendMock).toBeCalledTimes(1)
+ expect(beforeSendMock.mock.lastCall[0].event).toEqual('$$heatmap')
+ const heatmapData = beforeSendMock.mock.lastCall[0].properties.$heatmap_data
expect(heatmapData).toBeDefined()
expect(heatmapData['http://replaced/']).toHaveLength(4)
expect(heatmapData['http://replaced/'].map((x) => x.type)).toEqual(['click', 'click', 'rageclick', 'click'])
@@ -110,16 +109,16 @@ describe('heatmaps', () => {
jest.advanceTimersByTime(posthog.heatmaps!.flushIntervalMilliseconds + 1)
- expect(onCapture).toBeCalledTimes(1)
- expect(onCapture.mock.lastCall[0]).toEqual('$$heatmap')
- expect(onCapture.mock.lastCall[1].properties.$heatmap_data).toBeDefined()
- expect(onCapture.mock.lastCall[1].properties.$heatmap_data['http://replaced/']).toHaveLength(2)
+ expect(beforeSendMock).toBeCalledTimes(1)
+ expect(beforeSendMock.mock.lastCall[0].event).toEqual('$$heatmap')
+ expect(beforeSendMock.mock.lastCall[0].properties.$heatmap_data).toBeDefined()
+ expect(beforeSendMock.mock.lastCall[0].properties.$heatmap_data['http://replaced/']).toHaveLength(2)
expect(posthog.heatmaps!['buffer']).toEqual(undefined)
jest.advanceTimersByTime(posthog.heatmaps!.flushIntervalMilliseconds + 1)
- expect(onCapture).toBeCalledTimes(1)
+ expect(beforeSendMock).toBeCalledTimes(1)
})
it('should ignore clicks if they come from the toolbar', async () => {
@@ -143,17 +142,17 @@ describe('heatmaps', () => {
})
)
expect(posthog.heatmaps?.getAndClearBuffer()).not.toEqual(undefined)
- expect(onCapture.mock.calls).toEqual([])
+ expect(beforeSendMock.mock.calls).toEqual([])
})
it('should ignore an empty buffer', async () => {
- expect(onCapture.mock.calls).toEqual([])
+ expect(beforeSendMock.mock.calls).toEqual([])
expect(posthog.heatmaps?.['buffer']).toEqual(undefined)
jest.advanceTimersByTime(posthog.heatmaps!.flushIntervalMilliseconds + 1)
- expect(onCapture.mock.calls).toEqual([])
+ expect(beforeSendMock.mock.calls).toEqual([])
})
describe('isEnabled()', () => {
diff --git a/src/__tests__/identify.test.ts b/src/__tests__/identify.test.ts
index d435dbaec..d9f37571d 100644
--- a/src/__tests__/identify.test.ts
+++ b/src/__tests__/identify.test.ts
@@ -42,8 +42,8 @@ describe('identify', () => {
it('should send $is_identified = true with the identify event and following events', async () => {
// arrange
const token = uuidv7()
- const onCapture = jest.fn()
- const posthog = await createPosthogInstance(token, { _onCapture: onCapture })
+ const beforeSendMock = jest.fn().mockImplementation((e) => e)
+ const posthog = await createPosthogInstance(token, { before_send: beforeSendMock })
const distinctId = '123'
// act
@@ -52,12 +52,12 @@ describe('identify', () => {
posthog.capture('custom event after identify')
// assert
- const eventBeforeIdentify = onCapture.mock.calls[0]
- expect(eventBeforeIdentify[1].properties.$is_identified).toEqual(false)
- const identifyCall = onCapture.mock.calls[1]
- expect(identifyCall[0]).toEqual('$identify')
- expect(identifyCall[1].properties.$is_identified).toEqual(true)
- const eventAfterIdentify = onCapture.mock.calls[2]
- expect(eventAfterIdentify[1].properties.$is_identified).toEqual(true)
+ const eventBeforeIdentify = beforeSendMock.mock.calls[0]
+ expect(eventBeforeIdentify[0].properties.$is_identified).toEqual(false)
+ const identifyCall = beforeSendMock.mock.calls[1]
+ expect(identifyCall[0].event).toEqual('$identify')
+ expect(identifyCall[0].properties.$is_identified).toEqual(true)
+ const eventAfterIdentify = beforeSendMock.mock.calls[2]
+ expect(eventAfterIdentify[0].properties.$is_identified).toEqual(true)
})
})
diff --git a/src/__tests__/personProcessing.test.ts b/src/__tests__/personProcessing.test.ts
index 7cbfcb809..5bb11e653 100644
--- a/src/__tests__/personProcessing.test.ts
+++ b/src/__tests__/personProcessing.test.ts
@@ -78,13 +78,13 @@ describe('person processing', () => {
persistence_name?: string
) => {
token = token || uuidv7()
- const onCapture = jest.fn()
+ const beforeSendMock = jest.fn().mockImplementation((e) => e)
const posthog = await createPosthogInstance(token, {
- _onCapture: onCapture,
+ before_send: beforeSendMock,
person_profiles,
persistence_name,
})
- return { token, onCapture, posthog }
+ return { token, beforeSendMock, posthog }
}
describe('init', () => {
@@ -142,7 +142,7 @@ describe('person processing', () => {
describe('identify', () => {
it('should fail if process_person is set to never', async () => {
// arrange
- const { posthog, onCapture } = await setup('never')
+ const { posthog, beforeSendMock } = await setup('never')
// act
posthog.identify(distinctId)
@@ -152,12 +152,12 @@ describe('person processing', () => {
expect(jest.mocked(logger).error).toHaveBeenCalledWith(
'posthog.identify was called, but process_person is set to "never". This call will be ignored.'
)
- expect(onCapture).toBeCalledTimes(0)
+ expect(beforeSendMock).toBeCalledTimes(0)
})
it('should switch events to $person_process=true if process_person is identified_only', async () => {
// arrange
- const { posthog, onCapture } = await setup('identified_only')
+ const { posthog, beforeSendMock } = await setup('identified_only')
// act
posthog.capture('custom event before identify')
@@ -165,18 +165,18 @@ describe('person processing', () => {
posthog.capture('custom event after identify')
// assert
expect(jest.mocked(logger).error).toBeCalledTimes(0)
- const eventBeforeIdentify = onCapture.mock.calls[0]
- expect(eventBeforeIdentify[1].properties.$process_person_profile).toEqual(false)
- const identifyCall = onCapture.mock.calls[1]
- expect(identifyCall[0]).toEqual('$identify')
- expect(identifyCall[1].properties.$process_person_profile).toEqual(true)
- const eventAfterIdentify = onCapture.mock.calls[2]
- expect(eventAfterIdentify[1].properties.$process_person_profile).toEqual(true)
+ const eventBeforeIdentify = beforeSendMock.mock.calls[0]
+ expect(eventBeforeIdentify[0].properties.$process_person_profile).toEqual(false)
+ const identifyCall = beforeSendMock.mock.calls[1]
+ expect(identifyCall[0].event).toEqual('$identify')
+ expect(identifyCall[0].properties.$process_person_profile).toEqual(true)
+ const eventAfterIdentify = beforeSendMock.mock.calls[2]
+ expect(eventAfterIdentify[0].properties.$process_person_profile).toEqual(true)
})
it('should not change $person_process if process_person is always', async () => {
// arrange
- const { posthog, onCapture } = await setup('always')
+ const { posthog, beforeSendMock } = await setup('always')
// act
posthog.capture('custom event before identify')
@@ -184,26 +184,26 @@ describe('person processing', () => {
posthog.capture('custom event after identify')
// assert
expect(jest.mocked(logger).error).toBeCalledTimes(0)
- const eventBeforeIdentify = onCapture.mock.calls[0]
- expect(eventBeforeIdentify[1].properties.$process_person_profile).toEqual(true)
- const identifyCall = onCapture.mock.calls[1]
- expect(identifyCall[0]).toEqual('$identify')
- expect(identifyCall[1].properties.$process_person_profile).toEqual(true)
- const eventAfterIdentify = onCapture.mock.calls[2]
- expect(eventAfterIdentify[1].properties.$process_person_profile).toEqual(true)
+ const eventBeforeIdentify = beforeSendMock.mock.calls[0]
+ expect(eventBeforeIdentify[0].properties.$process_person_profile).toEqual(true)
+ const identifyCall = beforeSendMock.mock.calls[1]
+ expect(identifyCall[0].event).toEqual('$identify')
+ expect(identifyCall[0].properties.$process_person_profile).toEqual(true)
+ const eventAfterIdentify = beforeSendMock.mock.calls[2]
+ expect(eventAfterIdentify[0].properties.$process_person_profile).toEqual(true)
})
it('should include initial referrer info in identify event if identified_only', async () => {
// arrange
- const { posthog, onCapture } = await setup('identified_only')
+ const { posthog, beforeSendMock } = await setup('identified_only')
// act
posthog.identify(distinctId)
// assert
- const identifyCall = onCapture.mock.calls[0]
- expect(identifyCall[0]).toEqual('$identify')
- expect(identifyCall[1].$set_once).toEqual({
+ const identifyCall = beforeSendMock.mock.calls[0]
+ expect(identifyCall[0].event).toEqual('$identify')
+ expect(identifyCall[0].$set_once).toEqual({
...INITIAL_CAMPAIGN_PARAMS_NULL,
$initial_current_url: 'https://example.com?utm_source=foo',
$initial_host: 'example.com',
@@ -216,7 +216,7 @@ describe('person processing', () => {
it('should preserve initial referrer info across a separate session', async () => {
// arrange
- const { posthog, onCapture } = await setup('identified_only')
+ const { posthog, beforeSendMock } = await setup('identified_only')
mockReferrerGetter.mockReturnValue('https://referrer1.com')
mockURLGetter.mockReturnValue('https://example1.com/pathname1?utm_source=foo1')
@@ -236,17 +236,17 @@ describe('person processing', () => {
posthog.capture('event s2 after identify')
// assert
- const eventS1 = onCapture.mock.calls[0]
- const eventS2Before = onCapture.mock.calls[1]
- const eventS2Identify = onCapture.mock.calls[2]
- const eventS2After = onCapture.mock.calls[3]
+ const eventS1 = beforeSendMock.mock.calls[0]
+ const eventS2Before = beforeSendMock.mock.calls[1]
+ const eventS2Identify = beforeSendMock.mock.calls[2]
+ const eventS2After = beforeSendMock.mock.calls[3]
- expect(eventS1[1].$set_once).toEqual(undefined)
+ expect(eventS1[0].$set_once).toEqual(undefined)
- expect(eventS2Before[1].$set_once).toEqual(undefined)
+ expect(eventS2Before[0].$set_once).toEqual(undefined)
- expect(eventS2Identify[0]).toEqual('$identify')
- expect(eventS2Identify[1].$set_once).toEqual({
+ expect(eventS2Identify[0].event).toEqual('$identify')
+ expect(eventS2Identify[0].$set_once).toEqual({
...INITIAL_CAMPAIGN_PARAMS_NULL,
$initial_current_url: 'https://example1.com/pathname1?utm_source=foo1',
$initial_host: 'example1.com',
@@ -256,8 +256,8 @@ describe('person processing', () => {
$initial_utm_source: 'foo1',
})
- expect(eventS2After[0]).toEqual('event s2 after identify')
- expect(eventS2After[1].$set_once).toEqual({
+ expect(eventS2After[0].event).toEqual('event s2 after identify')
+ expect(eventS2After[0].$set_once).toEqual({
...INITIAL_CAMPAIGN_PARAMS_NULL,
$initial_current_url: 'https://example1.com/pathname1?utm_source=foo1',
$initial_host: 'example1.com',
@@ -270,15 +270,15 @@ describe('person processing', () => {
it('should include initial referrer info in identify event if always', async () => {
// arrange
- const { posthog, onCapture } = await setup('always')
+ const { posthog, beforeSendMock } = await setup('always')
// act
posthog.identify(distinctId)
// assert
- const identifyCall = onCapture.mock.calls[0]
- expect(identifyCall[0]).toEqual('$identify')
- expect(identifyCall[1].$set_once).toEqual({
+ const identifyCall = beforeSendMock.mock.calls[0]
+ expect(identifyCall[0].event).toEqual('$identify')
+ expect(identifyCall[0].$set_once).toEqual({
...INITIAL_CAMPAIGN_PARAMS_NULL,
$initial_current_url: 'https://example.com?utm_source=foo',
$initial_host: 'example.com',
@@ -291,16 +291,16 @@ describe('person processing', () => {
it('should include initial search params', async () => {
// arrange
- const { posthog, onCapture } = await setup('always')
+ const { posthog, beforeSendMock } = await setup('always')
mockReferrerGetter.mockReturnValue('https://www.google.com?q=bar')
// act
posthog.identify(distinctId)
// assert
- const identifyCall = onCapture.mock.calls[0]
- expect(identifyCall[0]).toEqual('$identify')
- expect(identifyCall[1].$set_once).toEqual({
+ const identifyCall = beforeSendMock.mock.calls[0]
+ expect(identifyCall[0].event).toEqual('$identify')
+ expect(identifyCall[0].$set_once).toEqual({
...INITIAL_CAMPAIGN_PARAMS_NULL,
$initial_current_url: 'https://example.com?utm_source=foo',
$initial_host: 'example.com',
@@ -315,7 +315,7 @@ describe('person processing', () => {
it('should be backwards compatible with deprecated INITIAL_REFERRER_INFO and INITIAL_CAMPAIGN_PARAMS way of saving initial person props', async () => {
// arrange
- const { posthog, onCapture } = await setup('always')
+ const { posthog, beforeSendMock } = await setup('always')
posthog.persistence!.props[INITIAL_REFERRER_INFO] = {
referrer: 'https://deprecated-referrer.com',
referring_domain: 'deprecated-referrer.com',
@@ -328,9 +328,9 @@ describe('person processing', () => {
posthog.identify(distinctId)
// assert
- const identifyCall = onCapture.mock.calls[0]
- expect(identifyCall[0]).toEqual('$identify')
- expect(identifyCall[1].$set_once).toEqual({
+ const identifyCall = beforeSendMock.mock.calls[0]
+ expect(identifyCall[0].event).toEqual('$identify')
+ expect(identifyCall[0].$set_once).toEqual({
$initial_referrer: 'https://deprecated-referrer.com',
$initial_referring_domain: 'deprecated-referrer.com',
$initial_utm_source: 'deprecated-source',
@@ -341,7 +341,7 @@ describe('person processing', () => {
describe('capture', () => {
it('should include initial referrer info iff the event has person processing when in identified_only mode', async () => {
// arrange
- const { posthog, onCapture } = await setup('identified_only')
+ const { posthog, beforeSendMock } = await setup('identified_only')
// act
posthog.capture('custom event before identify')
@@ -349,10 +349,10 @@ describe('person processing', () => {
posthog.capture('custom event after identify')
// assert
- const eventBeforeIdentify = onCapture.mock.calls[0]
- expect(eventBeforeIdentify[1].$set_once).toBeUndefined()
- const eventAfterIdentify = onCapture.mock.calls[2]
- expect(eventAfterIdentify[1].$set_once).toEqual({
+ const eventBeforeIdentify = beforeSendMock.mock.calls[0]
+ expect(eventBeforeIdentify[0].$set_once).toBeUndefined()
+ const eventAfterIdentify = beforeSendMock.mock.calls[2]
+ expect(eventAfterIdentify[0].$set_once).toEqual({
...INITIAL_CAMPAIGN_PARAMS_NULL,
$initial_current_url: 'https://example.com?utm_source=foo',
$initial_host: 'example.com',
@@ -365,7 +365,7 @@ describe('person processing', () => {
it('should add initial referrer to set_once when in always mode', async () => {
// arrange
- const { posthog, onCapture } = await setup('always')
+ const { posthog, beforeSendMock } = await setup('always')
// act
posthog.capture('custom event before identify')
@@ -373,8 +373,8 @@ describe('person processing', () => {
posthog.capture('custom event after identify')
// assert
- const eventBeforeIdentify = onCapture.mock.calls[0]
- expect(eventBeforeIdentify[1].$set_once).toEqual({
+ const eventBeforeIdentify = beforeSendMock.mock.calls[0]
+ expect(eventBeforeIdentify[0].$set_once).toEqual({
...INITIAL_CAMPAIGN_PARAMS_NULL,
$initial_current_url: 'https://example.com?utm_source=foo',
$initial_host: 'example.com',
@@ -383,8 +383,8 @@ describe('person processing', () => {
$initial_referring_domain: 'referrer.com',
$initial_utm_source: 'foo',
})
- const eventAfterIdentify = onCapture.mock.calls[2]
- expect(eventAfterIdentify[1].$set_once).toEqual({
+ const eventAfterIdentify = beforeSendMock.mock.calls[2]
+ expect(eventAfterIdentify[0].$set_once).toEqual({
...INITIAL_CAMPAIGN_PARAMS_NULL,
$initial_current_url: 'https://example.com?utm_source=foo',
$initial_host: 'example.com',
@@ -399,7 +399,7 @@ describe('person processing', () => {
describe('group', () => {
it('should start person processing for identified_only users', async () => {
// arrange
- const { posthog, onCapture } = await setup('identified_only')
+ const { posthog, beforeSendMock } = await setup('identified_only')
// act
posthog.capture('custom event before group')
@@ -407,18 +407,18 @@ describe('person processing', () => {
posthog.capture('custom event after group')
// assert
- const eventBeforeGroup = onCapture.mock.calls[0]
- expect(eventBeforeGroup[1].properties.$process_person_profile).toEqual(false)
- const groupIdentify = onCapture.mock.calls[1]
- expect(groupIdentify[0]).toEqual('$groupidentify')
- expect(groupIdentify[1].properties.$process_person_profile).toEqual(true)
- const eventAfterGroup = onCapture.mock.calls[2]
- expect(eventAfterGroup[1].properties.$process_person_profile).toEqual(true)
+ const eventBeforeGroup = beforeSendMock.mock.calls[0]
+ expect(eventBeforeGroup[0].properties.$process_person_profile).toEqual(false)
+ const groupIdentify = beforeSendMock.mock.calls[1]
+ expect(groupIdentify[0].event).toEqual('$groupidentify')
+ expect(groupIdentify[0].properties.$process_person_profile).toEqual(true)
+ const eventAfterGroup = beforeSendMock.mock.calls[2]
+ expect(eventAfterGroup[0].properties.$process_person_profile).toEqual(true)
})
it('should not send the $groupidentify event if person_processing is set to never', async () => {
// arrange
- const { posthog, onCapture } = await setup('never')
+ const { posthog, beforeSendMock } = await setup('never')
// act
posthog.capture('custom event before group')
@@ -431,24 +431,24 @@ describe('person processing', () => {
'posthog.group was called, but process_person is set to "never". This call will be ignored.'
)
- expect(onCapture).toBeCalledTimes(2)
- const eventBeforeGroup = onCapture.mock.calls[0]
- expect(eventBeforeGroup[1].properties.$process_person_profile).toEqual(false)
- const eventAfterGroup = onCapture.mock.calls[1]
- expect(eventAfterGroup[1].properties.$process_person_profile).toEqual(false)
+ expect(beforeSendMock).toBeCalledTimes(2)
+ const eventBeforeGroup = beforeSendMock.mock.calls[0]
+ expect(eventBeforeGroup[0].properties.$process_person_profile).toEqual(false)
+ const eventAfterGroup = beforeSendMock.mock.calls[1]
+ expect(eventAfterGroup[0].properties.$process_person_profile).toEqual(false)
})
})
describe('setPersonProperties', () => {
it("should not send a $set event if process_person is set to 'never'", async () => {
// arrange
- const { posthog, onCapture } = await setup('never')
+ const { posthog, beforeSendMock } = await setup('never')
// act
posthog.setPersonProperties({ prop: 'value' })
// assert
- expect(onCapture).toBeCalledTimes(0)
+ expect(beforeSendMock).toBeCalledTimes(0)
expect(jest.mocked(logger).error).toBeCalledTimes(1)
expect(jest.mocked(logger).error).toHaveBeenCalledWith(
'posthog.setPersonProperties was called, but process_person is set to "never". This call will be ignored.'
@@ -457,19 +457,19 @@ describe('person processing', () => {
it("should send a $set event if process_person is set to 'always'", async () => {
// arrange
- const { posthog, onCapture } = await setup('always')
+ const { posthog, beforeSendMock } = await setup('always')
// act
posthog.setPersonProperties({ prop: 'value' })
// assert
- expect(onCapture).toBeCalledTimes(1)
- expect(onCapture.mock.calls[0][0]).toEqual('$set')
+ expect(beforeSendMock).toBeCalledTimes(1)
+ expect(beforeSendMock.mock.calls[0][0].event).toEqual('$set')
})
it('should start person processing for identified_only users', async () => {
// arrange
- const { posthog, onCapture } = await setup('identified_only')
+ const { posthog, beforeSendMock } = await setup('identified_only')
// act
posthog.capture('custom event before setPersonProperties')
@@ -477,20 +477,20 @@ describe('person processing', () => {
posthog.capture('custom event after setPersonProperties')
// assert
- const eventBeforeGroup = onCapture.mock.calls[0]
- expect(eventBeforeGroup[1].properties.$process_person_profile).toEqual(false)
- const set = onCapture.mock.calls[1]
- expect(set[0]).toEqual('$set')
- expect(set[1].properties.$process_person_profile).toEqual(true)
- const eventAfterGroup = onCapture.mock.calls[2]
- expect(eventAfterGroup[1].properties.$process_person_profile).toEqual(true)
+ const eventBeforeGroup = beforeSendMock.mock.calls[0]
+ expect(eventBeforeGroup[0].properties.$process_person_profile).toEqual(false)
+ const set = beforeSendMock.mock.calls[1]
+ expect(set[0].event).toEqual('$set')
+ expect(set[0].properties.$process_person_profile).toEqual(true)
+ const eventAfterGroup = beforeSendMock.mock.calls[2]
+ expect(eventAfterGroup[0].properties.$process_person_profile).toEqual(true)
})
})
describe('alias', () => {
it('should start person processing for identified_only users', async () => {
// arrange
- const { posthog, onCapture } = await setup('identified_only')
+ const { posthog, beforeSendMock } = await setup('identified_only')
// act
posthog.capture('custom event before alias')
@@ -498,24 +498,24 @@ describe('person processing', () => {
posthog.capture('custom event after alias')
// assert
- const eventBeforeGroup = onCapture.mock.calls[0]
- expect(eventBeforeGroup[1].properties.$process_person_profile).toEqual(false)
- const alias = onCapture.mock.calls[1]
- expect(alias[0]).toEqual('$create_alias')
- expect(alias[1].properties.$process_person_profile).toEqual(true)
- const eventAfterGroup = onCapture.mock.calls[2]
- expect(eventAfterGroup[1].properties.$process_person_profile).toEqual(true)
+ const eventBeforeGroup = beforeSendMock.mock.calls[0]
+ expect(eventBeforeGroup[0].properties.$process_person_profile).toEqual(false)
+ const alias = beforeSendMock.mock.calls[1]
+ expect(alias[0].event).toEqual('$create_alias')
+ expect(alias[0].properties.$process_person_profile).toEqual(true)
+ const eventAfterGroup = beforeSendMock.mock.calls[2]
+ expect(eventAfterGroup[0].properties.$process_person_profile).toEqual(true)
})
it('should not send a $create_alias event if person processing is set to "never"', async () => {
// arrange
- const { posthog, onCapture } = await setup('never')
+ const { posthog, beforeSendMock } = await setup('never')
// act
posthog.alias('alias')
// assert
- expect(onCapture).toBeCalledTimes(0)
+ expect(beforeSendMock).toBeCalledTimes(0)
expect(jest.mocked(logger).error).toBeCalledTimes(1)
expect(jest.mocked(logger).error).toHaveBeenCalledWith(
'posthog.alias was called, but process_person is set to "never". This call will be ignored.'
@@ -526,7 +526,7 @@ describe('person processing', () => {
describe('createPersonProfile', () => {
it('should start person processing for identified_only users', async () => {
// arrange
- const { posthog, onCapture } = await setup('identified_only')
+ const { posthog, beforeSendMock } = await setup('identified_only')
// act
posthog.capture('custom event before createPersonProfile')
@@ -534,19 +534,19 @@ describe('person processing', () => {
posthog.capture('custom event after createPersonProfile')
// assert
- expect(onCapture.mock.calls.length).toEqual(3)
- const eventBeforeGroup = onCapture.mock.calls[0]
- expect(eventBeforeGroup[1].properties.$process_person_profile).toEqual(false)
- const set = onCapture.mock.calls[1]
- expect(set[0]).toEqual('$set')
- expect(set[1].properties.$process_person_profile).toEqual(true)
- const eventAfterGroup = onCapture.mock.calls[2]
- expect(eventAfterGroup[1].properties.$process_person_profile).toEqual(true)
+ expect(beforeSendMock.mock.calls.length).toEqual(3)
+ const eventBeforeGroup = beforeSendMock.mock.calls[0]
+ expect(eventBeforeGroup[0].properties.$process_person_profile).toEqual(false)
+ const set = beforeSendMock.mock.calls[1]
+ expect(set[0].event).toEqual('$set')
+ expect(set[0].properties.$process_person_profile).toEqual(true)
+ const eventAfterGroup = beforeSendMock.mock.calls[2]
+ expect(eventAfterGroup[0].properties.$process_person_profile).toEqual(true)
})
it('should do nothing if already has person profiles', async () => {
// arrange
- const { posthog, onCapture } = await setup('identified_only')
+ const { posthog, beforeSendMock } = await setup('identified_only')
// act
posthog.capture('custom event before createPersonProfile')
@@ -555,18 +555,18 @@ describe('person processing', () => {
posthog.createPersonProfile()
// assert
- expect(onCapture.mock.calls.length).toEqual(3)
+ expect(beforeSendMock.mock.calls.length).toEqual(3)
})
it("should not send an event if process_person is to set to 'always'", async () => {
// arrange
- const { posthog, onCapture } = await setup('always')
+ const { posthog, beforeSendMock } = await setup('always')
// act
posthog.createPersonProfile()
// assert
- expect(onCapture).toBeCalledTimes(0)
+ expect(beforeSendMock).toBeCalledTimes(0)
expect(jest.mocked(logger).error).toBeCalledTimes(0)
})
})
@@ -574,7 +574,7 @@ describe('person processing', () => {
describe('reset', () => {
it('should revert a back to anonymous state in identified_only', async () => {
// arrange
- const { posthog, onCapture } = await setup('identified_only')
+ const { posthog, beforeSendMock } = await setup('identified_only')
posthog.identify(distinctId)
posthog.capture('custom event before reset')
@@ -584,8 +584,8 @@ describe('person processing', () => {
// assert
expect(posthog._isIdentified()).toBe(false)
- expect(onCapture.mock.calls.length).toEqual(3)
- expect(onCapture.mock.calls[2][1].properties.$process_person_profile).toEqual(false)
+ expect(beforeSendMock.mock.calls.length).toEqual(3)
+ expect(beforeSendMock.mock.calls[2][0].properties.$process_person_profile).toEqual(false)
})
})
@@ -593,9 +593,13 @@ describe('person processing', () => {
it('should remember that a user set the mode to always on a previous visit', async () => {
// arrange
const persistenceName = uuidv7()
- const { posthog: posthog1, onCapture: onCapture1 } = await setup('always', undefined, persistenceName)
+ const { posthog: posthog1, beforeSendMock: beforeSendMock1 } = await setup(
+ 'always',
+ undefined,
+ persistenceName
+ )
posthog1.capture('custom event 1')
- const { posthog: posthog2, onCapture: onCapture2 } = await setup(
+ const { posthog: posthog2, beforeSendMock: beforeSendMock2 } = await setup(
'identified_only',
undefined,
persistenceName
@@ -605,38 +609,42 @@ describe('person processing', () => {
posthog2.capture('custom event 2')
// assert
- expect(onCapture1.mock.calls.length).toEqual(1)
- expect(onCapture2.mock.calls.length).toEqual(1)
- expect(onCapture1.mock.calls[0][1].properties.$process_person_profile).toEqual(true)
- expect(onCapture2.mock.calls[0][1].properties.$process_person_profile).toEqual(true)
+ expect(beforeSendMock1.mock.calls.length).toEqual(1)
+ expect(beforeSendMock2.mock.calls.length).toEqual(1)
+ expect(beforeSendMock1.mock.calls[0][0].properties.$process_person_profile).toEqual(true)
+ expect(beforeSendMock2.mock.calls[0][0].properties.$process_person_profile).toEqual(true)
})
it('should work when always is set on a later visit', async () => {
// arrange
const persistenceName = uuidv7()
- const { posthog: posthog1, onCapture: onCapture1 } = await setup(
+ const { posthog: posthog1, beforeSendMock: beforeSendMock1 } = await setup(
'identified_only',
undefined,
persistenceName
)
posthog1.capture('custom event 1')
- const { posthog: posthog2, onCapture: onCapture2 } = await setup('always', undefined, persistenceName)
+ const { posthog: posthog2, beforeSendMock: beforeSendMock2 } = await setup(
+ 'always',
+ undefined,
+ persistenceName
+ )
// act
posthog2.capture('custom event 2')
// assert
- expect(onCapture1.mock.calls.length).toEqual(1)
- expect(onCapture2.mock.calls.length).toEqual(1)
- expect(onCapture1.mock.calls[0][1].properties.$process_person_profile).toEqual(false)
- expect(onCapture2.mock.calls[0][1].properties.$process_person_profile).toEqual(true)
+ expect(beforeSendMock1.mock.calls.length).toEqual(1)
+ expect(beforeSendMock2.mock.calls.length).toEqual(1)
+ expect(beforeSendMock1.mock.calls[0][0].properties.$process_person_profile).toEqual(false)
+ expect(beforeSendMock2.mock.calls[0][0].properties.$process_person_profile).toEqual(true)
})
})
describe('decide', () => {
it('should change the person mode from default when decide response is handled', async () => {
// arrange
- const { posthog, onCapture } = await setup(undefined)
+ const { posthog, beforeSendMock } = await setup(undefined)
posthog.capture('startup page view')
// act
@@ -644,14 +652,14 @@ describe('person processing', () => {
posthog.capture('custom event')
// assert
- expect(onCapture.mock.calls.length).toEqual(2)
- expect(onCapture.mock.calls[0][1].properties.$process_person_profile).toEqual(false)
- expect(onCapture.mock.calls[1][1].properties.$process_person_profile).toEqual(true)
+ expect(beforeSendMock.mock.calls.length).toEqual(2)
+ expect(beforeSendMock.mock.calls[0][0].properties.$process_person_profile).toEqual(false)
+ expect(beforeSendMock.mock.calls[1][0].properties.$process_person_profile).toEqual(true)
})
it('should NOT change the person mode from user-defined when decide response is handled', async () => {
// arrange
- const { posthog, onCapture } = await setup('identified_only')
+ const { posthog, beforeSendMock } = await setup('identified_only')
posthog.capture('startup page view')
// act
@@ -659,27 +667,35 @@ describe('person processing', () => {
posthog.capture('custom event')
// assert
- expect(onCapture.mock.calls.length).toEqual(2)
- expect(onCapture.mock.calls[0][1].properties.$process_person_profile).toEqual(false)
- expect(onCapture.mock.calls[1][1].properties.$process_person_profile).toEqual(false)
+ expect(beforeSendMock.mock.calls.length).toEqual(2)
+ expect(beforeSendMock.mock.calls[0][0].properties.$process_person_profile).toEqual(false)
+ expect(beforeSendMock.mock.calls[1][0].properties.$process_person_profile).toEqual(false)
})
it('should persist when the default person mode is overridden by decide', async () => {
// arrange
const persistenceName = uuidv7()
- const { posthog: posthog1, onCapture: onCapture1 } = await setup(undefined, undefined, persistenceName)
+ const { posthog: posthog1, beforeSendMock: beforeSendMock1 } = await setup(
+ undefined,
+ undefined,
+ persistenceName
+ )
// act
posthog1._afterDecideResponse({ defaultIdentifiedOnly: false } as DecideResponse)
posthog1.capture('custom event 1')
- const { posthog: posthog2, onCapture: onCapture2 } = await setup(undefined, undefined, persistenceName)
+ const { posthog: posthog2, beforeSendMock: beforeSendMock2 } = await setup(
+ undefined,
+ undefined,
+ persistenceName
+ )
posthog2.capture('custom event 2')
// assert
- expect(onCapture1.mock.calls.length).toEqual(1)
- expect(onCapture2.mock.calls.length).toEqual(1)
- expect(onCapture1.mock.calls[0][1].properties.$process_person_profile).toEqual(true)
- expect(onCapture2.mock.calls[0][1].properties.$process_person_profile).toEqual(true)
+ expect(beforeSendMock1.mock.calls.length).toEqual(1)
+ expect(beforeSendMock2.mock.calls.length).toEqual(1)
+ expect(beforeSendMock1.mock.calls[0][0].properties.$process_person_profile).toEqual(true)
+ expect(beforeSendMock2.mock.calls[0][0].properties.$process_person_profile).toEqual(true)
})
})
})
diff --git a/src/__tests__/posthog-core.beforeSend.test.ts b/src/__tests__/posthog-core.beforeSend.test.ts
new file mode 100644
index 000000000..35d0a4641
--- /dev/null
+++ b/src/__tests__/posthog-core.beforeSend.test.ts
@@ -0,0 +1,179 @@
+import { uuidv7 } from '../uuidv7'
+import { defaultPostHog } from './helpers/posthog-instance'
+import { logger } from '../utils/logger'
+import { CaptureResult, knownUnsafeEditableEvent, PostHogConfig } from '../types'
+import { PostHog } from '../posthog-core'
+
+jest.mock('../utils/logger')
+
+const rejectingEventFn = () => {
+ return null
+}
+
+const editingEventFn = (captureResult: CaptureResult): CaptureResult => {
+ return {
+ ...captureResult,
+ properties: {
+ ...captureResult.properties,
+ edited: true,
+ },
+ $set: {
+ ...captureResult.$set,
+ edited: true,
+ },
+ }
+}
+
+describe('posthog core - before send', () => {
+ const baseUTCDateTime = new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
+ const eventName = '$event'
+
+ const posthogWith = (configOverride: Pick, 'before_send'>): PostHog => {
+ const posthog = defaultPostHog().init('testtoken', configOverride, uuidv7())
+ return Object.assign(posthog, {
+ _send_request: jest.fn(),
+ })
+ }
+
+ beforeEach(() => {
+ jest.useFakeTimers().setSystemTime(baseUTCDateTime)
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ it('can reject an event', () => {
+ const posthog = posthogWith({
+ before_send: rejectingEventFn,
+ })
+ ;(posthog._send_request as jest.Mock).mockClear()
+
+ const capturedData = posthog.capture(eventName, {}, {})
+
+ expect(capturedData).toBeUndefined()
+ expect(posthog._send_request).not.toHaveBeenCalled()
+ expect(jest.mocked(logger).info).toHaveBeenCalledWith(
+ `Event '${eventName}' was rejected in beforeSend function`
+ )
+ })
+
+ it('can edit an event', () => {
+ const posthog = posthogWith({
+ before_send: editingEventFn,
+ })
+ ;(posthog._send_request as jest.Mock).mockClear()
+
+ const capturedData = posthog.capture(eventName, {}, {})
+
+ expect(capturedData).toHaveProperty(['properties', 'edited'], true)
+ expect(capturedData).toHaveProperty(['$set', 'edited'], true)
+ expect(posthog._send_request).toHaveBeenCalledWith({
+ batchKey: undefined,
+ callback: expect.any(Function),
+ compression: 'best-available',
+ data: capturedData,
+ method: 'POST',
+ url: 'https://us.i.posthog.com/e/',
+ })
+ })
+
+ it('can take an array of fns', () => {
+ const posthog = posthogWith({
+ before_send: [
+ (cr) => {
+ cr.properties = { ...cr.properties, edited_one: true }
+ return cr
+ },
+ (cr) => {
+ if (cr.event === 'to reject') {
+ return null
+ }
+ return cr
+ },
+ (cr) => {
+ cr.properties = { ...cr.properties, edited_two: true }
+ return cr
+ },
+ ],
+ })
+ ;(posthog._send_request as jest.Mock).mockClear()
+
+ const capturedData = [posthog.capture(eventName, {}, {}), posthog.capture('to reject', {}, {})]
+
+ expect(capturedData.filter((cd) => !!cd)).toHaveLength(1)
+ expect(capturedData[0]).toHaveProperty(['properties', 'edited_one'], true)
+ expect(capturedData[0]).toHaveProperty(['properties', 'edited_one'], true)
+ expect(posthog._send_request).toHaveBeenCalledWith({
+ batchKey: undefined,
+ callback: expect.any(Function),
+ compression: 'best-available',
+ data: capturedData[0],
+ method: 'POST',
+ url: 'https://us.i.posthog.com/e/',
+ })
+ })
+
+ it('can sanitize $set event', () => {
+ const posthog = posthogWith({
+ before_send: (cr) => {
+ cr.$set = { value: 'edited' }
+ return cr
+ },
+ })
+ ;(posthog._send_request as jest.Mock).mockClear()
+
+ const capturedData = posthog.capture('$set', {}, { $set: { value: 'provided' } })
+
+ expect(capturedData).toHaveProperty(['$set', 'value'], 'edited')
+ expect(posthog._send_request).toHaveBeenCalledWith({
+ batchKey: undefined,
+ callback: expect.any(Function),
+ compression: 'best-available',
+ data: capturedData,
+ method: 'POST',
+ url: 'https://us.i.posthog.com/e/',
+ })
+ })
+
+ it('warned when making arbitrary event invalid', () => {
+ const posthog = posthogWith({
+ before_send: (cr) => {
+ cr.properties = undefined
+ return cr
+ },
+ })
+ ;(posthog._send_request as jest.Mock).mockClear()
+
+ const capturedData = posthog.capture(eventName, { value: 'provided' }, {})
+
+ expect(capturedData).not.toHaveProperty(['properties', 'value'], 'provided')
+ expect(posthog._send_request).toHaveBeenCalledWith({
+ batchKey: undefined,
+ callback: expect.any(Function),
+ compression: 'best-available',
+ data: capturedData,
+ method: 'POST',
+ url: 'https://us.i.posthog.com/e/',
+ })
+ expect(jest.mocked(logger).warn).toHaveBeenCalledWith(
+ `Event '${eventName}' has no properties after beforeSend function, this is likely an error.`
+ )
+ })
+
+ it('logs a warning when rejecting an unsafe to edit event', () => {
+ const posthog = posthogWith({
+ before_send: rejectingEventFn,
+ })
+ ;(posthog._send_request as jest.Mock).mockClear()
+ // chooses a random string from knownUnEditableEvent
+ const randomUnsafeEditableEvent =
+ knownUnsafeEditableEvent[Math.floor(Math.random() * knownUnsafeEditableEvent.length)]
+
+ posthog.capture(randomUnsafeEditableEvent, {}, {})
+
+ expect(jest.mocked(logger).warn).toHaveBeenCalledWith(
+ `Event '${randomUnsafeEditableEvent}' was rejected in beforeSend function. This can cause unexpected behavior.`
+ )
+ })
+})
diff --git a/src/__tests__/posthog-core.identify.test.ts b/src/__tests__/posthog-core.identify.test.ts
index 0d696480f..96beace88 100644
--- a/src/__tests__/posthog-core.identify.test.ts
+++ b/src/__tests__/posthog-core.identify.test.ts
@@ -7,15 +7,15 @@ jest.mock('../decide')
describe('identify()', () => {
let instance: PostHog
- let captureMock: jest.Mock
+ let beforeSendMock: jest.Mock
beforeEach(() => {
- captureMock = jest.fn()
+ beforeSendMock = jest.fn().mockImplementation((e) => e)
const posthog = defaultPostHog().init(
uuidv7(),
{
api_host: 'https://test.com',
- _onCapture: captureMock,
+ before_send: beforeSendMock,
},
uuidv7()
)
@@ -46,9 +46,9 @@ describe('identify()', () => {
instance.identify('calls capture when identity changes')
- expect(captureMock).toHaveBeenCalledWith(
- '$identify',
+ expect(beforeSendMock).toHaveBeenCalledWith(
expect.objectContaining({
+ event: '$identify',
properties: expect.objectContaining({
distinct_id: 'calls capture when identity changes',
$anon_distinct_id: 'oldIdentity',
@@ -72,9 +72,9 @@ describe('identify()', () => {
instance.identify('a-new-id')
- expect(captureMock).toHaveBeenCalledWith(
- '$identify',
+ expect(beforeSendMock).toHaveBeenCalledWith(
expect.objectContaining({
+ event: '$identify',
properties: expect.objectContaining({
distinct_id: 'a-new-id',
$anon_distinct_id: 'oldIdentity',
@@ -91,9 +91,9 @@ describe('identify()', () => {
instance.identify('a-new-id')
- expect(captureMock).toHaveBeenCalledWith(
- '$identify',
+ expect(beforeSendMock).toHaveBeenCalledWith(
expect.objectContaining({
+ event: '$identify',
properties: expect.objectContaining({
distinct_id: 'a-new-id',
$anon_distinct_id: 'oldIdentity',
@@ -115,7 +115,7 @@ describe('identify()', () => {
instance.identify('a-new-id')
- expect(captureMock).not.toHaveBeenCalled()
+ expect(beforeSendMock).not.toHaveBeenCalled()
expect(instance.featureFlags.setAnonymousDistinctId).not.toHaveBeenCalled()
})
@@ -130,7 +130,7 @@ describe('identify()', () => {
instance.identify('a-new-id')
- expect(captureMock).not.toHaveBeenCalled()
+ expect(beforeSendMock).not.toHaveBeenCalled()
expect(instance.featureFlags.setAnonymousDistinctId).not.toHaveBeenCalled()
})
@@ -141,9 +141,9 @@ describe('identify()', () => {
instance.identify('a-new-id')
- expect(captureMock).toHaveBeenCalledWith(
- '$identify',
+ expect(beforeSendMock).toHaveBeenCalledWith(
expect.objectContaining({
+ event: '$identify',
properties: expect.objectContaining({
distinct_id: 'a-new-id',
$anon_distinct_id: 'oldIdentity',
@@ -155,9 +155,9 @@ describe('identify()', () => {
it('calls capture with user properties if passed', () => {
instance.identify('a-new-id', { email: 'john@example.com' }, { howOftenAmISet: 'once!' })
- expect(captureMock).toHaveBeenCalledWith(
- '$identify',
+ expect(beforeSendMock).toHaveBeenCalledWith(
expect.objectContaining({
+ event: '$identify',
properties: expect.objectContaining({
distinct_id: 'a-new-id',
$anon_distinct_id: 'oldIdentity',
@@ -178,16 +178,16 @@ describe('identify()', () => {
it('does not capture or set user properties', () => {
instance.identify('a-new-id')
- expect(captureMock).not.toHaveBeenCalled()
+ expect(beforeSendMock).not.toHaveBeenCalled()
expect(instance.featureFlags.setAnonymousDistinctId).not.toHaveBeenCalled()
})
it('calls $set when user properties passed with same ID', () => {
instance.identify('a-new-id', { email: 'john@example.com' }, { howOftenAmISet: 'once!' })
- expect(captureMock).toHaveBeenCalledWith(
- '$set',
+ expect(beforeSendMock).toHaveBeenCalledWith(
expect.objectContaining({
+ event: '$set',
// get set at the top level and in properties
// $set: { email: 'john@example.com' },
// $set_once: expect.objectContaining({ howOftenAmISet: 'once!' }),
@@ -209,7 +209,7 @@ describe('identify()', () => {
instance.identify(null as unknown as string)
- expect(captureMock).not.toHaveBeenCalled()
+ expect(beforeSendMock).not.toHaveBeenCalled()
expect(instance.register).not.toHaveBeenCalled()
expect(console.error).toHaveBeenCalledWith(
'[PostHog.js]',
@@ -274,9 +274,9 @@ describe('identify()', () => {
it('captures a $set event', () => {
instance.setPersonProperties({ email: 'john@example.com' }, { name: 'john' })
- expect(captureMock).toHaveBeenCalledWith(
- '$set',
+ expect(beforeSendMock).toHaveBeenCalledWith(
expect.objectContaining({
+ event: '$set',
// get set at the top level and in properties
// $set: { email: 'john@example.com' },
// $set_once: expect.objectContaining({ name: 'john' }),
@@ -291,9 +291,9 @@ describe('identify()', () => {
it('calls proxies prople.set to setPersonProperties', () => {
instance.people.set({ email: 'john@example.com' })
- expect(captureMock).toHaveBeenCalledWith(
- '$set',
+ expect(beforeSendMock).toHaveBeenCalledWith(
expect.objectContaining({
+ event: '$set',
properties: expect.objectContaining({
$set: { email: 'john@example.com' },
$set_once: {},
@@ -306,9 +306,9 @@ describe('identify()', () => {
it('calls proxies prople.set_once to setPersonProperties', () => {
instance.people.set_once({ email: 'john@example.com' })
- expect(captureMock).toHaveBeenCalledWith(
- '$set',
+ expect(beforeSendMock).toHaveBeenCalledWith(
expect.objectContaining({
+ event: '$set',
properties: expect.objectContaining({
$set: {},
$set_once: { email: 'john@example.com' },
diff --git a/src/__tests__/posthog-core.test.ts b/src/__tests__/posthog-core.test.ts
index af1ca4b01..61d638339 100644
--- a/src/__tests__/posthog-core.test.ts
+++ b/src/__tests__/posthog-core.test.ts
@@ -2,36 +2,30 @@ import { defaultPostHog } from './helpers/posthog-instance'
import type { PostHogConfig } from '../types'
import { uuidv7 } from '../uuidv7'
-const mockReferrerGetter = jest.fn()
-const mockURLGetter = jest.fn()
-jest.mock('../utils/globals', () => {
- const orig = jest.requireActual('../utils/globals')
- return {
- ...orig,
- document: {
- ...orig.document,
- createElement: (...args: any[]) => orig.document.createElement(...args),
- get referrer() {
- return mockReferrerGetter?.()
- },
- get URL() {
- return mockURLGetter?.()
- },
- },
- get location() {
- const url = mockURLGetter?.()
- return {
- href: url,
- toString: () => url,
- }
- },
- }
-})
-
describe('posthog core', () => {
+ const mockURL = jest.fn()
+ const mockReferrer = jest.fn()
+
+ beforeAll(() => {
+ // Mock getters using Object.defineProperty
+ Object.defineProperty(document, 'URL', {
+ get: mockURL,
+ })
+ Object.defineProperty(document, 'referrer', {
+ get: mockReferrer,
+ })
+ Object.defineProperty(window, 'location', {
+ get: () => ({
+ href: mockURL(),
+ toString: () => mockURL(),
+ }),
+ configurable: true,
+ })
+ })
+
beforeEach(() => {
- mockReferrerGetter.mockReturnValue('https://referrer.com')
- mockURLGetter.mockReturnValue('https://example.com')
+ mockReferrer.mockReturnValue('https://referrer.com')
+ mockURL.mockReturnValue('https://example.com')
console.error = jest.fn()
})
@@ -45,10 +39,10 @@ describe('posthog core', () => {
event: 'prop',
}
const setup = (config: Partial = {}, token: string = uuidv7()) => {
- const onCapture = jest.fn()
- const posthog = defaultPostHog().init(token, { ...config, _onCapture: onCapture }, token)!
+ const beforeSendMock = jest.fn().mockImplementation((e) => e)
+ const posthog = defaultPostHog().init(token, { ...config, before_send: beforeSendMock }, token)!
posthog.debug()
- return { posthog, onCapture }
+ return { posthog, beforeSendMock }
}
it('respects property_denylist and property_blacklist', () => {
@@ -71,11 +65,11 @@ describe('posthog core', () => {
describe('rate limiting', () => {
it('includes information about remaining rate limit', () => {
- const { posthog, onCapture } = setup()
+ const { posthog, beforeSendMock } = setup()
posthog.capture(eventName, eventProperties)
- expect(onCapture.mock.calls[0][1]).toMatchObject({
+ expect(beforeSendMock.mock.calls[0][0]).toMatchObject({
properties: {
$lib_rate_limit_remaining_tokens: 99,
},
@@ -87,18 +81,18 @@ describe('posthog core', () => {
jest.setSystemTime(Date.now())
console.error = jest.fn()
- const { posthog, onCapture } = setup()
+ const { posthog, beforeSendMock } = setup()
for (let i = 0; i < 100; i++) {
posthog.capture(eventName, eventProperties)
}
- expect(onCapture).toHaveBeenCalledTimes(100)
- onCapture.mockClear()
+ expect(beforeSendMock).toHaveBeenCalledTimes(100)
+ beforeSendMock.mockClear()
;(console.error as any).mockClear()
for (let i = 0; i < 50; i++) {
posthog.capture(eventName, eventProperties)
}
- expect(onCapture).toHaveBeenCalledTimes(1)
- expect(onCapture.mock.calls[0][0]).toBe('$$client_ingestion_warning')
+ expect(beforeSendMock).toHaveBeenCalledTimes(1)
+ expect(beforeSendMock.mock.calls[0][0].event).toBe('$$client_ingestion_warning')
expect(console.error).toHaveBeenCalledTimes(50)
expect(console.error).toHaveBeenCalledWith(
'[PostHog.js]',
@@ -111,8 +105,8 @@ describe('posthog core', () => {
it("should send referrer info with the event's properties", () => {
// arrange
const token = uuidv7()
- mockReferrerGetter.mockReturnValue('https://referrer.example.com/some/path')
- const { posthog, onCapture } = setup({
+ mockReferrer.mockReturnValue('https://referrer.example.com/some/path')
+ const { posthog, beforeSendMock } = setup({
token,
persistence_name: token,
person_profiles: 'always',
@@ -122,7 +116,7 @@ describe('posthog core', () => {
posthog.capture(eventName, eventProperties)
// assert
- const { $set_once, properties } = onCapture.mock.calls[0][1]
+ const { $set_once, properties } = beforeSendMock.mock.calls[0][0]
expect($set_once['$initial_referrer']).toBe('https://referrer.example.com/some/path')
expect($set_once['$initial_referring_domain']).toBe('referrer.example.com')
expect(properties['$referrer']).toBe('https://referrer.example.com/some/path')
@@ -132,15 +126,15 @@ describe('posthog core', () => {
it('should not update the referrer within the same session', () => {
// arrange
const token = uuidv7()
- mockReferrerGetter.mockReturnValue('https://referrer1.example.com/some/path')
+ mockReferrer.mockReturnValue('https://referrer1.example.com/some/path')
const { posthog: posthog1 } = setup({
token,
persistence_name: token,
person_profiles: 'always',
})
posthog1.capture(eventName, eventProperties)
- mockReferrerGetter.mockReturnValue('https://referrer2.example.com/some/path')
- const { posthog: posthog2, onCapture: onCapture2 } = setup({
+ mockReferrer.mockReturnValue('https://referrer2.example.com/some/path')
+ const { posthog: posthog2, beforeSendMock } = setup({
token,
persistence_name: token,
})
@@ -153,7 +147,7 @@ describe('posthog core', () => {
'https://referrer1.example.com/some/path'
)
expect(posthog2.sessionPersistence!.props.$referrer).toEqual('https://referrer1.example.com/some/path')
- const { $set_once, properties } = onCapture2.mock.calls[0][1]
+ const { $set_once, properties } = beforeSendMock.mock.calls[0][0]
expect($set_once['$initial_referrer']).toBe('https://referrer1.example.com/some/path')
expect($set_once['$initial_referring_domain']).toBe('referrer1.example.com')
expect(properties['$referrer']).toBe('https://referrer1.example.com/some/path')
@@ -163,15 +157,15 @@ describe('posthog core', () => {
it('should use the new referrer in a new session', () => {
// arrange
const token = uuidv7()
- mockReferrerGetter.mockReturnValue('https://referrer1.example.com/some/path')
+ mockReferrer.mockReturnValue('https://referrer1.example.com/some/path')
const { posthog: posthog1 } = setup({
token,
persistence_name: token,
person_profiles: 'always',
})
posthog1.capture(eventName, eventProperties)
- mockReferrerGetter.mockReturnValue('https://referrer2.example.com/some/path')
- const { posthog: posthog2, onCapture: onCapture2 } = setup({
+ mockReferrer.mockReturnValue('https://referrer2.example.com/some/path')
+ const { posthog: posthog2, beforeSendMock: beforeSendMock2 } = setup({
token,
persistence_name: token,
})
@@ -184,7 +178,7 @@ describe('posthog core', () => {
expect(posthog2.persistence!.props.$initial_person_info.r).toEqual(
'https://referrer1.example.com/some/path'
)
- const { $set_once, properties } = onCapture2.mock.calls[0][1]
+ const { $set_once, properties } = beforeSendMock2.mock.calls[0][0]
expect($set_once['$initial_referrer']).toBe('https://referrer1.example.com/some/path')
expect($set_once['$initial_referring_domain']).toBe('referrer1.example.com')
expect(properties['$referrer']).toBe('https://referrer2.example.com/some/path')
@@ -194,8 +188,8 @@ describe('posthog core', () => {
it('should use $direct when there is no referrer', () => {
// arrange
const token = uuidv7()
- mockReferrerGetter.mockReturnValue('')
- const { posthog, onCapture } = setup({
+ mockReferrer.mockReturnValue('')
+ const { posthog, beforeSendMock } = setup({
token,
persistence_name: token,
person_profiles: 'always',
@@ -205,7 +199,7 @@ describe('posthog core', () => {
posthog.capture(eventName, eventProperties)
// assert
- const { $set_once, properties } = onCapture.mock.calls[0][1]
+ const { $set_once, properties } = beforeSendMock.mock.calls[0][0]
expect($set_once['$initial_referrer']).toBe('$direct')
expect($set_once['$initial_referring_domain']).toBe('$direct')
expect(properties['$referrer']).toBe('$direct')
@@ -217,8 +211,8 @@ describe('posthog core', () => {
it('should not send campaign params as null if there are no non-null ones', () => {
// arrange
const token = uuidv7()
- mockURLGetter.mockReturnValue('https://www.example.com/some/path')
- const { posthog, onCapture } = setup({
+ mockURL.mockReturnValue('https://www.example.com/some/path')
+ const { posthog, beforeSendMock } = setup({
token,
persistence_name: token,
})
@@ -227,15 +221,15 @@ describe('posthog core', () => {
posthog.capture('$pageview')
//assert
- expect(onCapture.mock.calls[0][1].properties).not.toHaveProperty('utm_source')
- expect(onCapture.mock.calls[0][1].properties).not.toHaveProperty('utm_medium')
+ expect(beforeSendMock.mock.calls[0][0].properties).not.toHaveProperty('utm_source')
+ expect(beforeSendMock.mock.calls[0][0].properties).not.toHaveProperty('utm_medium')
})
it('should send present campaign params, and nulls for others', () => {
// arrange
const token = uuidv7()
- mockURLGetter.mockReturnValue('https://www.example.com/some/path?utm_source=source')
- const { posthog, onCapture } = setup({
+ mockURL.mockReturnValue('https://www.example.com/some/path?utm_source=source')
+ const { posthog, beforeSendMock } = setup({
token,
persistence_name: token,
})
@@ -244,8 +238,8 @@ describe('posthog core', () => {
posthog.capture('$pageview')
//assert
- expect(onCapture.mock.calls[0][1].properties.utm_source).toBe('source')
- expect(onCapture.mock.calls[0][1].properties.utm_medium).toBe(null)
+ expect(beforeSendMock.mock.calls[0][0].properties.utm_source).toBe('source')
+ expect(beforeSendMock.mock.calls[0][0].properties.utm_medium).toBe(null)
})
})
})
diff --git a/src/__tests__/posthog-core.ts b/src/__tests__/posthog-core.ts
index a64fd0d46..7f37eec5d 100644
--- a/src/__tests__/posthog-core.ts
+++ b/src/__tests__/posthog-core.ts
@@ -96,7 +96,6 @@ describe('posthog core', () => {
{
property_denylist: [],
property_blacklist: [],
- _onCapture: jest.fn(),
store_google: true,
save_referrer: true,
},
@@ -168,13 +167,12 @@ describe('posthog core', () => {
'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36',
}
- const hook = jest.fn()
+ const hook = jest.fn().mockImplementation((event) => event)
const posthog = posthogWith(
{
opt_out_useragent_filter: true,
property_denylist: [],
property_blacklist: [],
- _onCapture: jest.fn(),
},
defaultOverrides
)
@@ -198,7 +196,6 @@ describe('posthog core', () => {
properties_string_max_length: 1000,
property_denylist: [],
property_blacklist: [],
- _onCapture: jest.fn(),
},
defaultOverrides
)
@@ -220,7 +217,6 @@ describe('posthog core', () => {
properties_string_max_length: undefined,
property_denylist: [],
property_blacklist: [],
- _onCapture: jest.fn(),
},
defaultOverrides
)
@@ -269,7 +265,6 @@ describe('posthog core', () => {
{
property_denylist: [],
property_blacklist: [],
- _onCapture: jest.fn(),
},
defaultOverrides
)
diff --git a/src/__tests__/posthog-persistence.test.ts b/src/__tests__/posthog-persistence.test.ts
index 3860ad30d..086246e24 100644
--- a/src/__tests__/posthog-persistence.test.ts
+++ b/src/__tests__/posthog-persistence.test.ts
@@ -3,6 +3,9 @@ import { PostHogPersistence } from '../posthog-persistence'
import { SESSION_ID, USER_STATE } from '../constants'
import { PostHogConfig } from '../types'
import Mock = jest.Mock
+import { PostHog } from '../posthog-core'
+import { window } from '../utils/globals'
+import { uuidv7 } from '../uuidv7'
let referrer = '' // No referrer by default
Object.defineProperty(document, 'referrer', { get: () => referrer })
@@ -203,4 +206,45 @@ describe('persistence', () => {
expect(lib.properties()).toEqual(expectedProps())
})
})
+
+ describe('posthog', () => {
+ it('should not store anything in localstorage, or cookies when in sessionStorage mode', () => {
+ const token = uuidv7()
+ const persistenceKey = `ph_${token}_posthog`
+ const posthog = new PostHog().init(token, {
+ persistence: 'sessionStorage',
+ })
+ posthog.register({ distinct_id: 'test', test_prop: 'test_val' })
+ posthog.capture('test_event')
+ expect(window.localStorage.getItem(persistenceKey)).toEqual(null)
+ expect(document.cookie).toEqual('')
+ expect(window.sessionStorage.getItem(persistenceKey)).toBeTruthy()
+ })
+
+ it('should not store anything in localstorage, sessionstorage, or cookies when in memory mode', () => {
+ const token = uuidv7()
+ const persistenceKey = `ph_${token}_posthog`
+ const posthog = new PostHog().init(token, {
+ persistence: 'memory',
+ })
+ posthog.register({ distinct_id: 'test', test_prop: 'test_val' })
+ posthog.capture('test_event')
+ expect(window.localStorage.getItem(persistenceKey)).toEqual(null)
+ expect(window.sessionStorage.getItem(persistenceKey)).toEqual(null)
+ expect(document.cookie).toEqual('')
+ })
+
+ it('should not store anything in cookies when in localstorage mode', () => {
+ const token = uuidv7()
+ const persistenceKey = `ph_${token}_posthog`
+ const posthog = new PostHog().init(token, {
+ persistence: 'localStorage',
+ })
+ posthog.register({ distinct_id: 'test', test_prop: 'test_val' })
+ posthog.capture('test_event')
+ expect(window.localStorage.getItem(persistenceKey)).toBeTruthy()
+ expect(window.sessionStorage.getItem(persistenceKey)).toBeTruthy()
+ expect(document.cookie).toEqual('')
+ })
+ })
})
diff --git a/src/__tests__/utils/before-send-utils.test.ts b/src/__tests__/utils/before-send-utils.test.ts
new file mode 100644
index 000000000..fe4139ad9
--- /dev/null
+++ b/src/__tests__/utils/before-send-utils.test.ts
@@ -0,0 +1,108 @@
+import { CaptureResult } from '../../types'
+import { isNull } from '../../utils/type-utils'
+import { sampleByDistinctId, sampleByEvent, sampleBySessionId } from '../../customizations/before-send'
+
+beforeAll(() => {
+ let fiftyFiftyRandom = true
+ Math.random = () => {
+ const val = fiftyFiftyRandom ? 0.48 : 0.51
+ fiftyFiftyRandom = !fiftyFiftyRandom
+ return val
+ }
+})
+
+describe('before send utils', () => {
+ it('can sample by event name', () => {
+ const sampleFn = sampleByEvent(['$autocapture'], 0.5)
+
+ const results = []
+ Array.from({ length: 100 }).forEach(() => {
+ const captureResult = { event: '$autocapture' } as unknown as CaptureResult
+ results.push(sampleFn(captureResult))
+ })
+ const emittedEvents = results.filter((r) => !isNull(r))
+
+ expect(emittedEvents.length).toBe(50)
+ expect(emittedEvents[0].properties).toMatchObject({
+ $sample_type: ['sampleByEvent'],
+ $sample_threshold: 0.5,
+ $sampled_events: ['$autocapture'],
+ })
+ })
+
+ it('can sample by distinct id', () => {
+ const sampleFn = sampleByDistinctId(0.5)
+ const results = []
+ const distinct_id_one = 'user-1'
+ const distinct_id_two = 'user-that-hashes-to-no-events'
+ Array.from({ length: 100 }).forEach(() => {
+ ;[distinct_id_one, distinct_id_two].forEach((distinct_id) => {
+ const captureResult = { properties: { distinct_id } } as unknown as CaptureResult
+ results.push(sampleFn(captureResult))
+ })
+ })
+ const distinctIdOneEvents = results.filter((r) => !isNull(r) && r.properties.distinct_id === distinct_id_one)
+ const distinctIdTwoEvents = results.filter((r) => !isNull(r) && r.properties.distinct_id === distinct_id_two)
+
+ expect(distinctIdOneEvents.length).toBe(100)
+ expect(distinctIdTwoEvents.length).toBe(0)
+
+ expect(distinctIdOneEvents[0].properties).toMatchObject({
+ $sample_type: ['sampleByDistinctId'],
+ $sample_threshold: 0.5,
+ })
+ })
+
+ it('can sample by session id', () => {
+ const sampleFn = sampleBySessionId(0.5)
+ const results = []
+ const session_id_one = 'a-session-id'
+ const session_id_two = 'id-that-hashes-to-not-sending-events'
+ Array.from({ length: 100 }).forEach(() => {
+ ;[session_id_one, session_id_two].forEach((session_id) => {
+ const captureResult = { properties: { $session_id: session_id } } as unknown as CaptureResult
+ results.push(sampleFn(captureResult))
+ })
+ })
+ const sessionIdOneEvents = results.filter((r) => !isNull(r) && r.properties.$session_id === session_id_one)
+ const sessionIdTwoEvents = results.filter((r) => !isNull(r) && r.properties.$session_id === session_id_two)
+
+ expect(sessionIdOneEvents.length).toBe(100)
+ expect(sessionIdTwoEvents.length).toBe(0)
+
+ expect(sessionIdOneEvents[0].properties).toMatchObject({
+ $sample_type: ['sampleBySessionId'],
+ $sample_threshold: 0.5,
+ })
+ })
+
+ it('can combine thresholds', () => {
+ const sampleBySession = sampleBySessionId(0.5)
+ const sampleByEventFn = sampleByEvent(['$autocapture'], 0.5)
+
+ const results = []
+ const session_id_one = 'a-session-id'
+ const session_id_two = 'id-that-hashes-to-not-sending-events'
+ Array.from({ length: 100 }).forEach(() => {
+ ;[session_id_one, session_id_two].forEach((session_id) => {
+ const captureResult = {
+ event: '$autocapture',
+ properties: { $session_id: session_id },
+ } as unknown as CaptureResult
+ const firstBySession = sampleBySession(captureResult)
+ const thenByEvent = sampleByEventFn(firstBySession)
+ results.push(thenByEvent)
+ })
+ })
+ const sessionIdOneEvents = results.filter((r) => !isNull(r) && r.properties.$session_id === session_id_one)
+ const sessionIdTwoEvents = results.filter((r) => !isNull(r) && r.properties.$session_id === session_id_two)
+
+ expect(sessionIdOneEvents.length).toBe(50)
+ expect(sessionIdTwoEvents.length).toBe(0)
+
+ expect(sessionIdOneEvents[0].properties).toMatchObject({
+ $sample_type: ['sampleBySessionId', 'sampleByEvent'],
+ $sample_threshold: 0.25,
+ })
+ })
+})
diff --git a/src/autocapture.ts b/src/autocapture.ts
index 8fc25d580..cf9289e3a 100644
--- a/src/autocapture.ts
+++ b/src/autocapture.ts
@@ -15,7 +15,7 @@ import {
splitClassString,
} from './autocapture-utils'
import RageClick from './extensions/rageclick'
-import { AutocaptureConfig, DecideResponse, Properties } from './types'
+import { AutocaptureConfig, COPY_AUTOCAPTURE_EVENT, DecideResponse, EventName, Properties } from './types'
import { PostHog } from './posthog-core'
import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from './constants'
@@ -25,8 +25,6 @@ import { document, window } from './utils/globals'
import { convertToURL } from './utils/request-utils'
import { isDocumentFragment, isElementNode, isTag, isTextNode } from './utils/element-utils'
-const COPY_AUTOCAPTURE_EVENT = '$copy_autocapture'
-
function limitText(length: number, text: string): string {
if (text.length > length) {
return text.slice(0, length) + '...'
@@ -343,7 +341,7 @@ export class Autocapture {
return !disabledClient && !disabledServer
}
- private _captureEvent(e: Event, eventName = '$autocapture'): boolean | void {
+ private _captureEvent(e: Event, eventName: EventName = '$autocapture'): boolean | void {
if (!this.isEnabled) {
return
}
diff --git a/src/config.ts b/src/config.ts
index 3c4adce82..51ddf6941 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,10 +1,10 @@
-import { version } from '../package.json'
+import packageInfo from '../package.json'
// overridden in posthog-core,
// e.g. Config.DEBUG = Config.DEBUG || instance.config.debug
const Config = {
DEBUG: false,
- LIB_VERSION: version,
+ LIB_VERSION: packageInfo.version,
}
export default Config
diff --git a/src/constants.ts b/src/constants.ts
index f86c52ee4..594f1503b 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -13,7 +13,6 @@ export const EVENT_TIMERS_KEY = '__timers'
export const AUTOCAPTURE_DISABLED_SERVER_SIDE = '$autocapture_disabled_server_side'
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 DEAD_CLICKS_ENABLED_SERVER_SIDE = '$dead_clicks_enabled_server_side'
export const WEB_VITALS_ALLOWED_METRICS = '$web_vitals_allowed_metrics'
@@ -27,6 +26,8 @@ export const SESSION_ID = '$sesid'
export const SESSION_RECORDING_IS_SAMPLED = '$session_is_sampled'
export const SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION = '$session_recording_url_trigger_activated_session'
export const SESSION_RECORDING_URL_TRIGGER_STATUS = '$session_recording_url_trigger_status'
+export const SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION = '$session_recording_event_trigger_activated_session'
+export const SESSION_RECORDING_EVENT_TRIGGER_STATUS = '$session_recording_event_trigger_status'
export const ENABLED_FEATURE_FLAGS = '$enabled_feature_flags'
export const PERSISTENCE_EARLY_ACCESS_FEATURES = '$early_access_features'
export const STORED_PERSON_PROPERTIES_KEY = '$stored_person_properties'
diff --git a/src/customizations/before-send.ts b/src/customizations/before-send.ts
new file mode 100644
index 000000000..0f28c4d4a
--- /dev/null
+++ b/src/customizations/before-send.ts
@@ -0,0 +1,119 @@
+import { clampToRange } from '../utils/number-utils'
+import { BeforeSendFn, CaptureResult, KnownEventName } from '../types'
+import { includes } from '../utils'
+import { isArray, isUndefined } from '../utils/type-utils'
+
+function appendArray(currentValue: string[] | undefined, sampleType: string | string[]): string[] {
+ return [...(currentValue ? currentValue : []), ...(isArray(sampleType) ? sampleType : [sampleType])]
+}
+
+function updateThreshold(currentValue: number | undefined, percent: number): number {
+ return (isUndefined(currentValue) ? 1 : currentValue) * percent
+}
+
+function simpleHash(str: string) {
+ let hash = 0
+ for (let i = 0; i < str.length; i++) {
+ hash = (hash << 5) - hash + str.charCodeAt(i) // (hash * 31) + char code
+ hash |= 0 // Convert to 32bit integer
+ }
+ return Math.abs(hash)
+}
+
+/*
+ * receives percent as a number between 0 and 1
+ */
+function sampleOnProperty(prop: string, percent: number): boolean {
+ return simpleHash(prop) % 100 < clampToRange(percent * 100, 0, 100)
+}
+
+/**
+ * Provides an implementation of sampling that samples based on the distinct ID.
+ * Using the provided percentage.
+ * Can be used to create a beforeCapture fn for a PostHog instance.
+ *
+ * Setting 0.5 will cause roughly 50% of distinct ids to have events sent.
+ * Not 50% of events for each distinct id.
+ *
+ * @param percent a number from 0 to 1, 1 means always send and, 0 means never send the event
+ */
+export function sampleByDistinctId(percent: number): BeforeSendFn {
+ return (captureResult: CaptureResult | null): CaptureResult | null => {
+ if (!captureResult) {
+ return null
+ }
+
+ return sampleOnProperty(captureResult.properties.distinct_id, percent)
+ ? {
+ ...captureResult,
+ properties: {
+ ...captureResult.properties,
+ $sample_type: ['sampleByDistinctId'],
+ $sample_threshold: percent,
+ },
+ }
+ : null
+ }
+}
+
+/**
+ * Provides an implementation of sampling that samples based on the session ID.
+ * Using the provided percentage.
+ * Can be used to create a beforeCapture fn for a PostHog instance.
+ *
+ * Setting 0.5 will cause roughly 50% of sessions to have events sent.
+ * Not 50% of events for each session.
+ *
+ * @param percent a number from 0 to 1, 1 means always send and, 0 means never send the event
+ */
+export function sampleBySessionId(percent: number): BeforeSendFn {
+ return (captureResult: CaptureResult | null): CaptureResult | null => {
+ if (!captureResult) {
+ return null
+ }
+
+ return sampleOnProperty(captureResult.properties.$session_id, percent)
+ ? {
+ ...captureResult,
+ properties: {
+ ...captureResult.properties,
+ $sample_type: appendArray(captureResult.properties.$sample_type, 'sampleBySessionId'),
+ $sample_threshold: updateThreshold(captureResult.properties.$sample_threshold, percent),
+ },
+ }
+ : null
+ }
+}
+
+/**
+ * Provides an implementation of sampling that samples based on the event name.
+ * Using the provided percentage.
+ * Can be used to create a beforeCapture fn for a PostHog instance.
+ *
+ * @param eventNames an array of event names to sample, sampling is applied across events not per event name
+ * @param percent a number from 0 to 1, 1 means always send, 0 means never send the event
+ */
+export function sampleByEvent(eventNames: KnownEventName[], percent: number): BeforeSendFn {
+ return (captureResult: CaptureResult | null): CaptureResult | null => {
+ if (!captureResult) {
+ return null
+ }
+
+ if (!includes(eventNames, captureResult.event)) {
+ return captureResult
+ }
+
+ const number = Math.random()
+ return number * 100 < clampToRange(percent * 100, 0, 100)
+ ? {
+ ...captureResult,
+ properties: {
+ ...captureResult.properties,
+ $sample_type: appendArray(captureResult.properties?.$sample_type, 'sampleByEvent'),
+ $sample_threshold: updateThreshold(captureResult.properties?.$sample_threshold, percent),
+ $sampled_events: appendArray(captureResult.properties?.$sampled_events, eventNames),
+ },
+ }
+ : null
+ }
+}
diff --git a/src/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags.ts b/src/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags.ts
new file mode 100644
index 000000000..42b7f52f9
--- /dev/null
+++ b/src/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags.ts
@@ -0,0 +1,15 @@
+import { PostHog } from '../posthog-core'
+import { CAMPAIGN_PARAMS, EVENT_TO_PERSON_PROPERTIES, Info } from '../utils/event-utils'
+import { each, extend, includes } from '../utils'
+
+export const setAllPersonProfilePropertiesAsPersonPropertiesForFlags = (posthog: PostHog): void => {
+ const allProperties = extend({}, Info.properties(), Info.campaignParams(), Info.referrerInfo())
+ const personProperties: Record = {}
+ each(allProperties, function (v, k: string) {
+ if (includes(CAMPAIGN_PARAMS, k) || includes(EVENT_TO_PERSON_PROPERTIES, k)) {
+ personProperties[k] = v
+ }
+ })
+
+ posthog.setPersonPropertiesForFlags(personProperties)
+}
diff --git a/src/entrypoints/dead-clicks-autocapture.ts b/src/entrypoints/dead-clicks-autocapture.ts
index bb4c5d1a1..496879686 100644
--- a/src/entrypoints/dead-clicks-autocapture.ts
+++ b/src/entrypoints/dead-clicks-autocapture.ts
@@ -5,6 +5,7 @@ import { autocaptureCompatibleElements, getEventTarget } from '../autocapture-ut
import { DeadClickCandidate, DeadClicksAutoCaptureConfig, Properties } from '../types'
import { autocapturePropertiesForElement } from '../autocapture'
import { isElementInToolbar, isElementNode, isTag } from '../utils/element-utils'
+import { getNativeMutationObserverImplementation } from '../utils/prototype-utils'
function asClick(event: MouseEvent): DeadClickCandidate | null {
const eventTarget = getEventTarget(event)
@@ -66,7 +67,8 @@ class LazyLoadedDeadClicksAutocapture implements LazyLoadedDeadClicksAutocapture
private _startMutationObserver(observerTarget: Node) {
if (!this._mutationObserver) {
- this._mutationObserver = new MutationObserver((mutations) => {
+ const NativeMutationObserver = getNativeMutationObserverImplementation(assignableWindow)
+ this._mutationObserver = new NativeMutationObserver((mutations) => {
this.onMutation(mutations)
})
this._mutationObserver.observe(observerTarget, {
diff --git a/src/entrypoints/recorder.ts b/src/entrypoints/recorder.ts
index 134b252ff..ffda9496a 100644
--- a/src/entrypoints/recorder.ts
+++ b/src/entrypoints/recorder.ts
@@ -309,7 +309,7 @@ function initXhrObserver(cb: networkCallback, win: IWindow, options: Required {
const requests = prepareRequest({
entry,
- method: req.method,
+ method: method,
status: xhr?.status,
networkRequest,
start,
@@ -386,7 +386,7 @@ function prepareRequest({
timeOrigin,
timestamp,
method: method,
- initiatorType: entry ? (entry.initiatorType as InitiatorType) : initiatorType,
+ initiatorType: initiatorType ? initiatorType : entry ? (entry.initiatorType as InitiatorType) : undefined,
status,
requestHeaders: networkRequest.requestHeaders,
requestBody: networkRequest.requestBody,
diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts
index f4354d1eb..bfa2693a9 100644
--- a/src/extensions/replay/sessionrecording.ts
+++ b/src/extensions/replay/sessionrecording.ts
@@ -2,12 +2,12 @@ import {
CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE,
SESSION_RECORDING_CANVAS_RECORDING,
SESSION_RECORDING_ENABLED_SERVER_SIDE,
+ SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION,
SESSION_RECORDING_IS_SAMPLED,
SESSION_RECORDING_MINIMUM_DURATION,
SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE,
SESSION_RECORDING_SAMPLE_RATE,
SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION,
- SESSION_RECORDING_URL_TRIGGER_STATUS,
} from '../../constants'
import {
estimateSize,
@@ -19,6 +19,7 @@ import {
} from './sessionrecording-utils'
import { PostHog } from '../../posthog-core'
import {
+ CaptureResult,
DecideResponse,
FlagVariant,
NetworkRecordOptions,
@@ -43,14 +44,17 @@ import { isLocalhost } from '../../utils/request-utils'
import { MutationRateLimiter } from './mutation-rate-limiter'
import { gzipSync, strFromU8, strToU8 } from 'fflate'
import { clampToRange } from '../../utils/number-utils'
+import { includes } from '../../utils'
type SessionStartReason =
- | 'sampling_override'
+ | 'sampling_overridden'
| 'recording_initialized'
- | 'linked_flag_match'
- | 'linked_flag_override'
- | 'sampling'
+ | 'linked_flag_matched'
+ | 'linked_flag_overridden'
+ | 'sampled'
| 'session_id_changed'
+ | 'url_trigger_matched'
+ | 'event_trigger_matched'
const BASE_ENDPOINT = '/s/'
@@ -75,8 +79,8 @@ const ACTIVE_SOURCES = [
IncrementalSource.Drag,
]
-const TRIGGER_STATUSES = ['trigger_activated', 'trigger_pending', 'trigger_disabled'] as const
-type TriggerStatus = typeof TRIGGER_STATUSES[number]
+export type TriggerType = 'url' | 'event'
+type TriggerStatus = 'trigger_activated' | 'trigger_pending' | 'trigger_disabled'
/**
* Session recording starts in buffering mode while waiting for decide response
@@ -266,6 +270,9 @@ export class SessionRecording {
private _urlBlocked: boolean = false
+ private _eventTriggers: string[] = []
+ private _removeEventTriggerCaptureHook: (() => void) | undefined = undefined
+
// Util to help developers working on this feature manually override
_forceAllowLocalhostNetworkCapture = false
@@ -291,7 +298,7 @@ export class SessionRecording {
}
private get fullSnapshotIntervalMillis(): number {
- if (this.urlTriggerStatus === 'trigger_pending') {
+ if (this.triggerStatus === 'trigger_pending') {
return ONE_MINUTE
}
@@ -389,7 +396,7 @@ export class SessionRecording {
return 'buffering'
}
- if (this.urlTriggerStatus === 'trigger_pending') {
+ if (this.triggerStatus === 'trigger_pending') {
return 'buffering'
}
@@ -409,27 +416,25 @@ export class SessionRecording {
return 'trigger_disabled'
}
- const currentStatus = this.instance?.get_property(SESSION_RECORDING_URL_TRIGGER_STATUS)
const currentTriggerSession = this.instance?.get_property(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION)
+ return currentTriggerSession === this.sessionId ? 'trigger_activated' : 'trigger_pending'
+ }
- if (currentTriggerSession !== this.sessionId) {
- this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION)
- this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_STATUS)
- return 'trigger_pending'
- }
-
- if (TRIGGER_STATUSES.includes(currentStatus)) {
- return currentStatus as TriggerStatus
+ private get eventTriggerStatus(): TriggerStatus {
+ if (this._eventTriggers.length === 0) {
+ return 'trigger_disabled'
}
- return 'trigger_pending'
+ const currentTriggerSession = this.instance?.get_property(SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION)
+ return currentTriggerSession === this.sessionId ? 'trigger_activated' : 'trigger_pending'
}
- private set urlTriggerStatus(status: TriggerStatus) {
- this.instance?.persistence?.register({
- [SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION]: this.sessionId,
- [SESSION_RECORDING_URL_TRIGGER_STATUS]: status,
- })
+ private get triggerStatus(): TriggerStatus {
+ const eitherIsActivated =
+ this.eventTriggerStatus === 'trigger_activated' || this.urlTriggerStatus === 'trigger_activated'
+ const eitherIsPending =
+ this.eventTriggerStatus === 'trigger_pending' || this.urlTriggerStatus === 'trigger_pending'
+ return eitherIsActivated ? 'trigger_activated' : eitherIsPending ? 'trigger_pending' : 'trigger_disabled'
}
constructor(private readonly instance: PostHog) {
@@ -491,6 +496,8 @@ export class SessionRecording {
// so we call this here _and_ in the decide response
this._setupSampling()
+ this._addEventTriggerListener()
+
if (isNullish(this._removePageViewCaptureHook)) {
// :TRICKY: rrweb does not capture navigation within SPA-s, so hook into our $pageview events to get access to all events.
// Dropping the initial event is fine (it's always captured by rrweb).
@@ -516,8 +523,8 @@ export class SessionRecording {
if (changeReason) {
this._tryAddCustomEvent('$session_id_change', { sessionId, windowId, changeReason })
+ this.instance?.persistence?.unregister(SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION)
this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION)
- this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_STATUS)
}
})
}
@@ -542,6 +549,8 @@ export class SessionRecording {
this._removePageViewCaptureHook?.()
this._removePageViewCaptureHook = undefined
+ this._removeEventTriggerCaptureHook?.()
+ this._removeEventTriggerCaptureHook = undefined
this._onSessionIdListener?.()
this._onSessionIdListener = undefined
this._samplingSessionListener?.()
@@ -586,7 +595,7 @@ export class SessionRecording {
if (makeDecision) {
if (shouldSample) {
- this._reportStarted('sampling')
+ this._reportStarted('sampled')
} else {
logger.warn(
LOGGER_PREFIX +
@@ -623,14 +632,10 @@ export class SessionRecording {
const flagIsPresent = isObject(variants) && linkedFlag in variants
const linkedFlagMatches = linkedVariant ? variants[linkedFlag] === linkedVariant : flagIsPresent
if (linkedFlagMatches) {
- const payload = {
+ this._reportStarted('linked_flag_matched', {
linkedFlag,
linkedVariant,
- }
- const tag = 'linked flag matched'
- logger.info(LOGGER_PREFIX + ' ' + tag, payload)
- this._tryAddCustomEvent(tag, payload)
- this._reportStarted('linked_flag_match')
+ })
}
this._linkedFlagSeen = linkedFlagMatches
})
@@ -644,6 +649,10 @@ export class SessionRecording {
this._urlBlocklist = response.sessionRecording.urlBlocklist
}
+ if (response.sessionRecording?.eventTriggers) {
+ this._eventTriggers = response.sessionRecording.eventTriggers
+ }
+
this.receivedDecide = true
this.startIfEnabledOrStop()
}
@@ -1012,7 +1021,7 @@ export class SessionRecording {
}
// Check if the URL matches any trigger patterns
- this._checkTriggerConditions()
+ this._checkUrlTriggerConditions()
if (this.status === 'paused' && !isRecordingPausedEvent(rawEvent)) {
return
@@ -1024,7 +1033,7 @@ export class SessionRecording {
}
// Clear the buffer if waiting for a trigger, and only keep data from after the current full snapshot
- if (rawEvent.type === EventType.FullSnapshot && this.urlTriggerStatus === 'trigger_pending') {
+ if (rawEvent.type === EventType.FullSnapshot && this.triggerStatus === 'trigger_pending') {
this.clearBuffer()
}
@@ -1205,7 +1214,7 @@ export class SessionRecording {
})
}
- private _checkTriggerConditions() {
+ private _checkUrlTriggerConditions() {
if (typeof window === 'undefined' || !window.location.href) {
return
}
@@ -1222,16 +1231,21 @@ export class SessionRecording {
}
if (sessionRecordingUrlTriggerMatches(url, this._urlTriggers)) {
- this._activateUrlTrigger()
+ this._activateTrigger('url')
}
}
- private _activateUrlTrigger() {
- if (this.urlTriggerStatus === 'trigger_pending') {
- this.urlTriggerStatus = 'trigger_activated'
- this._tryAddCustomEvent('url trigger activated', {})
+ private _activateTrigger(triggerType: TriggerType) {
+ if (this.triggerStatus === 'trigger_pending') {
+ // status is stored separately for URL and event triggers
+ this.instance?.persistence?.register({
+ [triggerType === 'url'
+ ? SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION
+ : SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION]: this.sessionId,
+ })
+
this._flushBuffer()
- logger.info(LOGGER_PREFIX + ' recording triggered by URL pattern match')
+ this._reportStarted((triggerType + '_trigger_matched') as SessionStartReason)
}
}
@@ -1239,9 +1253,6 @@ export class SessionRecording {
if (this.status === 'paused') {
return
}
- logger.info(LOGGER_PREFIX + ' recording paused due to URL blocker')
-
- this._tryAddCustomEvent('recording paused', { reason: 'url blocker' })
this._urlBlocked = true
document?.body?.classList?.add('ph-no-capture')
@@ -1253,6 +1264,9 @@ export class SessionRecording {
setTimeout(() => {
this._flushBuffer()
}, 100)
+
+ logger.info(LOGGER_PREFIX + ' recording paused due to URL blocker')
+ this._tryAddCustomEvent('recording paused', { reason: 'url blocker' })
}
private _resumeRecording() {
@@ -1264,27 +1278,43 @@ export class SessionRecording {
document?.body?.classList?.remove('ph-no-capture')
this._tryTakeFullSnapshot()
-
this._scheduleFullSnapshot()
+
this._tryAddCustomEvent('recording resumed', { reason: 'left blocked url' })
logger.info(LOGGER_PREFIX + ' recording resumed')
}
+ private _addEventTriggerListener() {
+ if (this._eventTriggers.length === 0 || !isNullish(this._removeEventTriggerCaptureHook)) {
+ return
+ }
+
+ this._removeEventTriggerCaptureHook = this.instance.on('eventCaptured', (event: CaptureResult) => {
+ // If anything could go wrong here it has the potential to block the main loop,
+ // so we catch all errors.
+ try {
+ if (this._eventTriggers.includes(event.event)) {
+ this._activateTrigger('event')
+ }
+ } catch (e) {
+ logger.error(LOGGER_PREFIX + 'Could not activate event trigger', e)
+ }
+ })
+ }
+
/**
- * this ignores the linked flag config and causes capture to start
- * (if recording would have started had the flag been received i.e. it does not override other config).
+ * this ignores the linked flag config and (if other conditions are met) causes capture to start
*
* It is not usual to call this directly,
* instead call `posthog.startSessionRecording({linked_flag: true})`
* */
public overrideLinkedFlag() {
this._linkedFlagSeen = true
- this._reportStarted('linked_flag_override')
+ this._reportStarted('linked_flag_overridden')
}
/**
- * this ignores the sampling config and causes capture to start
- * (if recording would have started had the flag been received i.e. it does not override other config).
+ * this ignores the sampling config and (if other conditions are met) causes capture to start
*
* It is not usual to call this directly,
* instead call `posthog.startSessionRecording({sampling: true})`
@@ -1294,14 +1324,26 @@ export class SessionRecording {
// short-circuits the `makeSamplingDecision` function in the session recording module
[SESSION_RECORDING_IS_SAMPLED]: true,
})
- this._reportStarted('sampling_override')
+ this._reportStarted('sampling_overridden')
}
- private _reportStarted(startReason: SessionStartReason, shouldReport: () => boolean = () => true) {
- if (shouldReport()) {
- this.instance.register_for_session({
- $session_recording_start_reason: startReason,
- })
+ /**
+ * this ignores the URL/Event trigger config and (if other conditions are met) causes capture to start
+ *
+ * It is not usual to call this directly,
+ * instead call `posthog.startSessionRecording({trigger: 'url' | 'event'})`
+ * */
+ public overrideTrigger(triggerType: TriggerType) {
+ this._activateTrigger(triggerType)
+ }
+
+ private _reportStarted(startReason: SessionStartReason, tagPayload?: Record) {
+ this.instance.register_for_session({
+ $session_recording_start_reason: startReason,
+ })
+ logger.info(LOGGER_PREFIX + ' ' + startReason.replace('_', ' '), tagPayload)
+ if (!includes(['recording_initialized', 'session_id_changed'], startReason)) {
+ this._tryAddCustomEvent(startReason, tagPayload)
}
}
}
diff --git a/src/posthog-core.ts b/src/posthog-core.ts
index 6e2bc3e7c..db362d2af 100644
--- a/src/posthog-core.ts
+++ b/src/posthog-core.ts
@@ -34,6 +34,7 @@ import {
Compression,
DecideResponse,
EarlyAccessFeatureCallback,
+ EventName,
IsFeatureEnabledOptions,
JsonType,
PostHogConfig,
@@ -54,10 +55,11 @@ import { uuidv7 } from './uuidv7'
import { Survey, SurveyCallback, SurveyQuestionBranchingType } from './posthog-surveys-types'
import {
isArray,
- isBoolean,
isEmptyObject,
isEmptyString,
isFunction,
+ isKnownUnsafeEditableEvent,
+ isNullish,
isNumber,
isObject,
isString,
@@ -181,6 +183,7 @@ export const defaultConfig = (): PostHogConfig => ({
session_idle_timeout_seconds: 30 * 60, // 30 minutes
person_profiles: 'identified_only',
__add_tracing_headers: false,
+ before_send: undefined,
})
export const configRenames = (origConfig: Partial): Partial => {
@@ -412,7 +415,7 @@ export class PostHog {
this.persistence = new PostHogPersistence(this.config)
this.sessionPersistence =
- this.config.persistence === 'sessionStorage'
+ this.config.persistence === 'sessionStorage' || this.config.persistence === 'memory'
? this.persistence
: new PostHogPersistence({ ...this.config, persistence: 'sessionStorage' })
@@ -528,7 +531,8 @@ export class PostHog {
this._loaded()
}
- if (isFunction(this.config._onCapture)) {
+ if (isFunction(this.config._onCapture) && this.config._onCapture !== __NOOP) {
+ logger.warn('onCapture is deprecated. Please use `before_send` instead')
this.on('eventCaptured', (data) => this.config._onCapture(data.event, data))
}
@@ -564,7 +568,6 @@ export class PostHog {
this.experiments?.afterDecideResponse(response)
this.surveys?.afterDecideResponse(response)
this.webVitalsAutocapture?.afterDecideResponse(response)
- this.exceptions?.afterDecideResponse(response)
this.exceptionObserver?.afterDecideResponse(response)
this.deadClicksAutocapture?.afterDecideResponse(response)
}
@@ -787,7 +790,11 @@ export class PostHog {
* @param {String} [config.transport] Transport method for network request ('XHR' or 'sendBeacon').
* @param {Date} [config.timestamp] Timestamp is a Date object. If not set, it'll automatically be set to the current time.
*/
- capture(event_name: string, properties?: Properties | null, options?: CaptureOptions): CaptureResult | undefined {
+ capture(
+ event_name: EventName,
+ properties?: Properties | null,
+ options?: CaptureOptions
+ ): CaptureResult | undefined {
// While developing, a developer might purposefully _not_ call init(),
// in this case, we would like capture to be a noop.
if (!this.__loaded || !this.persistence || !this.sessionPersistence || !this._requestQueue) {
@@ -870,6 +877,15 @@ export class PostHog {
this.setPersonPropertiesForFlags(finalSet)
}
+ if (!isNullish(this.config.before_send)) {
+ const beforeSendResult = this._runBeforeSend(data)
+ if (!beforeSendResult) {
+ return
+ } else {
+ data = beforeSendResult
+ }
+ }
+
this._internalEventEmitter.emit('eventCaptured', data)
const requestOptions: QueuedRequestOptions = {
@@ -1786,7 +1802,7 @@ export class PostHog {
this.persistence?.update_config(this.config, oldConfig)
this.sessionPersistence =
- this.config.persistence === 'sessionStorage'
+ this.config.persistence === 'sessionStorage' || this.config.persistence === 'memory'
? this.persistence
: new PostHogPersistence({ ...this.config, persistence: 'sessionStorage' })
@@ -1814,22 +1830,42 @@ export class PostHog {
* turns session recording on, and updates the config option `disable_session_recording` to false
* @param override.sampling - optional boolean to override the default sampling behavior - ensures the next session recording to start will not be skipped by sampling config.
* @param override.linked_flag - optional boolean to override the default linked_flag behavior - ensures the next session recording to start will not be skipped by linked_flag config.
+ * @param override.url_trigger - optional boolean to override the default url_trigger behavior - ensures the next session recording to start will not be skipped by url_trigger config.
+ * @param override.event_trigger - optional boolean to override the default event_trigger behavior - ensures the next session recording to start will not be skipped by event_trigger config.
* @param override - optional boolean to override the default sampling behavior - ensures the next session recording to start will not be skipped by sampling or linked_flag config. `true` is shorthand for { sampling: true, linked_flag: true }
*/
- startSessionRecording(override?: { sampling?: boolean; linked_flag?: boolean } | true): void {
- const overrideAll = isBoolean(override) && override
- if (overrideAll || override?.sampling || override?.linked_flag) {
+ startSessionRecording(
+ override?: { sampling?: boolean; linked_flag?: boolean; url_trigger?: true; event_trigger?: true } | true
+ ): void {
+ const overrideAll = override === true
+ const overrideConfig = {
+ sampling: overrideAll || !!override?.sampling,
+ linked_flag: overrideAll || !!override?.linked_flag,
+ url_trigger: overrideAll || !!override?.url_trigger,
+ event_trigger: overrideAll || !!override?.event_trigger,
+ }
+
+ if (Object.values(overrideConfig).some(Boolean)) {
// allow the session id check to rotate session id if necessary
- const ids = this.sessionManager?.checkAndGetSessionAndWindowId()
- if (overrideAll || override?.sampling) {
+ this.sessionManager?.checkAndGetSessionAndWindowId()
+
+ if (overrideConfig.sampling) {
this.sessionRecording?.overrideSampling()
- logger.info('Session recording started with sampling override for session: ', ids?.sessionId)
}
- if (overrideAll || override?.linked_flag) {
+
+ if (overrideConfig.linked_flag) {
this.sessionRecording?.overrideLinkedFlag()
- logger.info('Session recording started with linked_flags override')
+ }
+
+ if (overrideConfig.url_trigger) {
+ this.sessionRecording?.overrideTrigger('url')
+ }
+
+ if (overrideConfig.event_trigger) {
+ this.sessionRecording?.overrideTrigger('event')
}
}
+
this.set_config({ disable_session_recording: false })
}
@@ -2040,7 +2076,7 @@ export class PostHog {
* @param {Object} [config.capture_properties] Set of properties to be captured along with the opt-in action
*/
opt_in_capturing(options?: {
- captureEventName?: string | null | false /** event name to be used for capturing the opt-in action */
+ captureEventName?: EventName | null | false /** event name to be used for capturing the opt-in action */
captureProperties?: Properties /** set of properties to be captured along with the opt-in action */
}): void {
this.consent.optInOut(true)
@@ -2141,6 +2177,33 @@ export class PostHog {
this.set_config({ debug: true })
}
}
+
+ private _runBeforeSend(data: CaptureResult): CaptureResult | null {
+ if (isNullish(this.config.before_send)) {
+ return data
+ }
+
+ const fns = isArray(this.config.before_send) ? this.config.before_send : [this.config.before_send]
+ let beforeSendResult: CaptureResult | null = data
+ for (const fn of fns) {
+ beforeSendResult = fn(beforeSendResult)
+ if (isNullish(beforeSendResult)) {
+ const logMessage = `Event '${data.event}' was rejected in beforeSend function`
+ if (isKnownUnsafeEditableEvent(data.event)) {
+ logger.warn(`${logMessage}. This can cause unexpected behavior.`)
+ } else {
+ logger.info(logMessage)
+ }
+ return null
+ }
+ if (!beforeSendResult.properties || isEmptyObject(beforeSendResult.properties)) {
+ logger.warn(
+ `Event '${data.event}' has no properties after beforeSend function, this is likely an error.`
+ )
+ }
+ }
+ return beforeSendResult
+ }
}
safewrapClass(PostHog, ['identify'])
diff --git a/src/posthog-exceptions.ts b/src/posthog-exceptions.ts
index f8cc687a2..d964ecc10 100644
--- a/src/posthog-exceptions.ts
+++ b/src/posthog-exceptions.ts
@@ -1,41 +1,8 @@
-import { EXCEPTION_CAPTURE_ENDPOINT_SUFFIX } from './constants'
import { PostHog } from './posthog-core'
-import { DecideResponse, Properties } from './types'
-import { isObject } from './utils/type-utils'
-
-// TODO: move this to /x/ as default
-export const BASE_ERROR_ENDPOINT_SUFFIX = '/e/'
+import { Properties } from './types'
export class PostHogExceptions {
- private _endpointSuffix: string
-
- constructor(private readonly instance: PostHog) {
- // TODO: once BASE_ERROR_ENDPOINT_SUFFIX is no longer /e/ this can be removed
- this._endpointSuffix =
- this.instance.persistence?.props[EXCEPTION_CAPTURE_ENDPOINT_SUFFIX] || BASE_ERROR_ENDPOINT_SUFFIX
- }
-
- get endpoint() {
- // Always respect any api_host set by the client config
- return this.instance.requestRouter.endpointFor('api', this._endpointSuffix)
- }
-
- afterDecideResponse(response: DecideResponse) {
- const autocaptureExceptionsResponse = response.autocaptureExceptions
-
- this._endpointSuffix = isObject(autocaptureExceptionsResponse)
- ? autocaptureExceptionsResponse.endpoint || BASE_ERROR_ENDPOINT_SUFFIX
- : BASE_ERROR_ENDPOINT_SUFFIX
-
- if (this.instance.persistence) {
- // when we come to moving the endpoint to not /e/
- // we'll want that to persist between startup and decide response
- // TODO: once BASE_ENDPOINT is no longer /e/ this can be removed
- this.instance.persistence.register({
- [EXCEPTION_CAPTURE_ENDPOINT_SUFFIX]: this._endpointSuffix,
- })
- }
- }
+ constructor(private readonly instance: PostHog) {}
/**
* :TRICKY: Make sure we batch these requests
@@ -44,7 +11,6 @@ export class PostHogExceptions {
this.instance.capture('$exception', properties, {
_noTruncate: true,
_batchKey: 'exceptionEvent',
- _url: this.endpoint,
})
}
}
diff --git a/src/types.ts b/src/types.ts
index d54c161c1..d7994968a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -5,9 +5,60 @@ import { recordOptions } from './extensions/replay/sessionrecording-utils'
export type Property = any
export type Properties = Record
+export const COPY_AUTOCAPTURE_EVENT = '$copy_autocapture'
+
+export const knownUnsafeEditableEvent = [
+ '$snapshot',
+ '$pageview',
+ '$pageleave',
+ '$set',
+ 'survey dismissed',
+ 'survey sent',
+ 'survey shown',
+ '$identify',
+ '$groupidentify',
+ '$create_alias',
+ '$$client_ingestion_warning',
+ '$web_experiment_applied',
+ '$feature_enrollment_update',
+ '$feature_flag_called',
+] as const
+
+/**
+ * These events can be processed by the `beforeCapture` function
+ * but can cause unexpected confusion in data.
+ *
+ * Some features of PostHog rely on receiving 100% of these events
+ */
+export type KnownUnsafeEditableEvent = typeof knownUnsafeEditableEvent[number]
+
+/**
+ * These are known events PostHog events that can be processed by the `beforeCapture` function
+ * That means PostHog functionality does not rely on receiving 100% of these for calculations
+ * So, it is safe to sample them to reduce the volume of events sent to PostHog
+ */
+export type KnownEventName =
+ | '$heatmaps_data'
+ | '$opt_in'
+ | '$exception'
+ | '$$heatmap'
+ | '$web_vitals'
+ | '$dead_click'
+ | '$autocapture'
+ | typeof COPY_AUTOCAPTURE_EVENT
+ | '$rageclick'
+
+export type EventName =
+ | KnownUnsafeEditableEvent
+ | KnownEventName
+ // magic value so that the type of EventName is a set of known strings or any other string
+ // which means you get autocomplete for known strings
+ // but no type complaints when you add an arbitrary string
+ | (string & {})
+
export interface CaptureResult {
uuid: string
- event: string
+ event: EventName
properties: Properties
$set?: Properties
$set_once?: Properties
@@ -162,6 +213,8 @@ export interface HeatmapConfig {
flush_interval_milliseconds: number
}
+export type BeforeSendFn = (cr: CaptureResult | null) => CaptureResult | null
+
export interface PostHogConfig {
api_host: string
/** @deprecated - This property is no longer supported */
@@ -237,7 +290,18 @@ export interface PostHogConfig {
feature_flag_request_timeout_ms: number
get_device_id: (uuid: string) => string
name: string
+ /**
+ * this is a read-only function that can be used to react to event capture
+ * @deprecated - use `before_send` instead - NB before_send is not read only
+ */
_onCapture: (eventName: string, eventData: CaptureResult) => void
+ /**
+ * This function or array of functions - if provided - are called immediately before sending data to the server.
+ * It allows you to edit data before it is sent, or choose not to send it all.
+ * if provided as an array the functions are called in the order they are provided
+ * any one function returning null means the event will not be sent
+ */
+ before_send?: BeforeSendFn | BeforeSendFn[]
capture_performance?: boolean | PerformanceCaptureConfig
// Should only be used for testing. Could negatively impact performance.
disable_compression: boolean
@@ -452,6 +516,7 @@ export interface DecideResponse {
networkPayloadCapture?: Pick
urlTriggers?: SessionRecordingUrlTrigger[]
urlBlocklist?: SessionRecordingUrlTrigger[]
+ eventTriggers?: string[]
}
surveys?: boolean
toolbarParams: ToolbarParams
@@ -670,7 +735,6 @@ export interface ErrorConversions {
}
export interface SessionRecordingUrlTrigger {
- urlBlockList?: SessionRecordingUrlTrigger[]
url: string
matching: 'regex'
}
diff --git a/src/utils/event-utils.ts b/src/utils/event-utils.ts
index 3f12553a7..120b3f5ba 100644
--- a/src/utils/event-utils.ts
+++ b/src/utils/event-utils.ts
@@ -31,6 +31,25 @@ export const CAMPAIGN_PARAMS = [
'rdt_cid', // reddit
]
+export const EVENT_TO_PERSON_PROPERTIES = [
+ // mobile params
+ '$app_build',
+ '$app_name',
+ '$app_namespace',
+ '$app_version',
+ // web params
+ '$browser',
+ '$browser_version',
+ '$device_type',
+ '$current_url',
+ '$pathname',
+ '$os',
+ '$os_name', // $os_name is a special case, it's treated as an alias of $os!
+ '$os_version',
+ '$referring_domain',
+ '$referrer',
+]
+
export const Info = {
campaignParams: function (customParams?: string[]): Record {
if (!document) {
diff --git a/src/utils/globals.ts b/src/utils/globals.ts
index e23e10365..377fe22b5 100644
--- a/src/utils/globals.ts
+++ b/src/utils/globals.ts
@@ -16,6 +16,12 @@ import { DeadClicksAutoCaptureConfig, ErrorEventArgs, ErrorMetadata, Properties
// eslint-disable-next-line no-restricted-globals
const win: (Window & typeof globalThis) | undefined = typeof window !== 'undefined' ? window : undefined
+export type AssignableWindow = Window &
+ typeof globalThis &
+ Record & {
+ __PosthogExtensions__?: PostHogExtensions
+ }
+
/**
* This is our contract between (potentially) lazily loaded extensions and the SDK
* changes to this interface can be breaking changes for users of the SDK
@@ -86,10 +92,6 @@ export const XMLHttpRequest =
global?.XMLHttpRequest && 'withCredentials' in new global.XMLHttpRequest() ? global.XMLHttpRequest : undefined
export const AbortController = global?.AbortController
export const userAgent = navigator?.userAgent
-export const assignableWindow: Window &
- typeof globalThis &
- Record & {
- __PosthogExtensions__?: PostHogExtensions
- } = win ?? ({} as any)
+export const assignableWindow: AssignableWindow = win ?? ({} as any)
export { win as window }
diff --git a/src/utils/prototype-utils.ts b/src/utils/prototype-utils.ts
new file mode 100644
index 000000000..5a18b8b49
--- /dev/null
+++ b/src/utils/prototype-utils.ts
@@ -0,0 +1,60 @@
+/**
+ * adapted from https://github.com/getsentry/sentry-javascript/blob/72751dacb88c5b970d8bac15052ee8e09b28fd5d/packages/browser-utils/src/getNativeImplementation.ts#L27
+ * and https://github.com/PostHog/rrweb/blob/804380afbb1b9bed70b8792cb5a25d827f5c0cb5/packages/utils/src/index.ts#L31
+ * after a number of performance reports from Angular users
+ */
+
+import { AssignableWindow } from './globals'
+import { isAngularZonePatchedFunction, isFunction, isNativeFunction } from './type-utils'
+import { logger } from './logger'
+
+interface NativeImplementationsCache {
+ MutationObserver: typeof MutationObserver
+}
+
+const cachedImplementations: Partial = {}
+
+export function getNativeImplementation(
+ name: T,
+ assignableWindow: AssignableWindow
+): NativeImplementationsCache[T] {
+ const cached = cachedImplementations[name]
+ if (cached) {
+ return cached
+ }
+
+ let impl = assignableWindow[name] as NativeImplementationsCache[T]
+
+ if (isNativeFunction(impl) && !isAngularZonePatchedFunction(impl)) {
+ return (cachedImplementations[name] = impl.bind(assignableWindow) as NativeImplementationsCache[T])
+ }
+
+ const document = assignableWindow.document
+ if (document && isFunction(document.createElement)) {
+ try {
+ const sandbox = document.createElement('iframe')
+ sandbox.hidden = true
+ document.head.appendChild(sandbox)
+ const contentWindow = sandbox.contentWindow
+ if (contentWindow && (contentWindow as any)[name]) {
+ impl = (contentWindow as any)[name] as NativeImplementationsCache[T]
+ }
+ document.head.removeChild(sandbox)
+ } catch (e) {
+ // Could not create sandbox iframe, just use assignableWindow.xxx
+ logger.warn(`Could not create sandbox iframe for ${name} check, bailing to assignableWindow.${name}: `, e)
+ }
+ }
+
+ // Sanity check: This _should_ not happen, but if it does, we just skip caching...
+ // This can happen e.g. in tests where fetch may not be available in the env, or similar.
+ if (!impl || !isFunction(impl)) {
+ return impl
+ }
+
+ return (cachedImplementations[name] = impl.bind(assignableWindow) as NativeImplementationsCache[T])
+}
+
+export function getNativeMutationObserverImplementation(assignableWindow: AssignableWindow): typeof MutationObserver {
+ return getNativeImplementation('MutationObserver', assignableWindow)
+}
diff --git a/src/utils/type-utils.ts b/src/utils/type-utils.ts
index 2d7192f23..797116c57 100644
--- a/src/utils/type-utils.ts
+++ b/src/utils/type-utils.ts
@@ -1,3 +1,6 @@
+import { includes } from '.'
+import { knownUnsafeEditableEvent, KnownUnsafeEditableEvent } from '../types'
+
// eslint-disable-next-line posthog-js/no-direct-array-check
const nativeIsArray = Array.isArray
const ObjProto = Object.prototype
@@ -9,22 +12,33 @@ export const isArray =
function (obj: any): obj is any[] {
return toString.call(obj) === '[object Array]'
}
-export const isUint8Array = function (x: unknown): x is Uint8Array {
- return toString.call(x) === '[object Uint8Array]'
-}
+
// from a comment on http://dbj.org/dbj/?p=286
// fails on only one very rare and deliberate custom object:
// let bomb = { toString : undefined, valueOf: function(o) { return "function BOMBA!"; }};
-export const isFunction = function (f: any): f is (...args: any[]) => any {
+export const isFunction = (x: unknown): x is (...args: any[]) => any => {
// eslint-disable-next-line posthog-js/no-direct-function-check
- return typeof f === 'function'
+ return typeof x === 'function'
+}
+
+export const isNativeFunction = (x: unknown): x is (...args: any[]) => any =>
+ isFunction(x) && x.toString().indexOf('[native code]') !== -1
+
+// When angular patches functions they pass the above `isNativeFunction` check
+export const isAngularZonePatchedFunction = (x: unknown): boolean => {
+ if (!isFunction(x)) {
+ return false
+ }
+ const prototypeKeys = Object.getOwnPropertyNames(x.prototype || {})
+ return prototypeKeys.some((key) => key.indexOf('__zone'))
}
+
// Underscore Addons
-export const isObject = function (x: unknown): x is Record {
+export const isObject = (x: unknown): x is Record => {
// eslint-disable-next-line posthog-js/no-direct-object-check
return x === Object(x) && !isArray(x)
}
-export const isEmptyObject = function (x: unknown): x is Record {
+export const isEmptyObject = (x: unknown): x is Record => {
if (isObject(x)) {
for (const key in x) {
if (hasOwnProperty.call(x, key)) {
@@ -35,20 +49,16 @@ export const isEmptyObject = function (x: unknown): x is Record {
}
return false
}
-export const isUndefined = function (x: unknown): x is undefined {
- return x === void 0
-}
+export const isUndefined = (x: unknown): x is undefined => x === void 0
-export const isString = function (x: unknown): x is string {
+export const isString = (x: unknown): x is string => {
// eslint-disable-next-line posthog-js/no-direct-string-check
return toString.call(x) == '[object String]'
}
-export const isEmptyString = function (x: unknown): boolean {
- return isString(x) && x.trim().length === 0
-}
+export const isEmptyString = (x: unknown): boolean => isString(x) && x.trim().length === 0
-export const isNull = function (x: unknown): x is null {
+export const isNull = (x: unknown): x is null => {
// eslint-disable-next-line posthog-js/no-direct-null-check
return x === null
}
@@ -57,19 +67,13 @@ export const isNull = function (x: unknown): x is null {
sometimes you want to check if something is null or undefined
that's what this is for
*/
-export const isNullish = function (x: unknown): x is null | undefined {
- return isUndefined(x) || isNull(x)
-}
+export const isNullish = (x: unknown): x is null | undefined => isUndefined(x) || isNull(x)
-export const isDate = function (x: unknown): x is Date {
- // eslint-disable-next-line posthog-js/no-direct-date-check
- return toString.call(x) == '[object Date]'
-}
-export const isNumber = function (x: unknown): x is number {
+export const isNumber = (x: unknown): x is number => {
// eslint-disable-next-line posthog-js/no-direct-number-check
return toString.call(x) == '[object Number]'
}
-export const isBoolean = function (x: unknown): x is boolean {
+export const isBoolean = (x: unknown): x is boolean => {
// eslint-disable-next-line posthog-js/no-direct-boolean-check
return toString.call(x) === '[object Boolean]'
}
@@ -88,3 +92,7 @@ export const isFile = (x: unknown): x is File => {
// eslint-disable-next-line posthog-js/no-direct-file-check
return x instanceof File
}
+
+export const isKnownUnsafeEditableEvent = (x: unknown): x is KnownUnsafeEditableEvent => {
+ return includes(knownUnsafeEditableEvent as unknown as string[], x)
+}
diff --git a/testcafe/helpers.js b/testcafe/helpers.js
index 5f3312298..b1f2613cc 100644
--- a/testcafe/helpers.js
+++ b/testcafe/helpers.js
@@ -77,8 +77,9 @@ export const initPosthog = (testName, config) => {
window.loaded = true
window.fullCaptures = []
}
- clientPosthogConfig._onCapture = (_, event) => {
+ clientPosthogConfig.before_send = (event) => {
window.fullCaptures.push(event)
+ return event
}
window.posthog.init(clientPosthogConfig.api_key, clientPosthogConfig)
window.posthog.register(register)