diff --git a/cypress/e2e/before_send.cy.ts b/cypress/e2e/before_send.cy.ts
new file mode 100644
index 000000000..1502e69b4
--- /dev/null
+++ b/cypress/e2e/before_send.cy.ts
@@ -0,0 +1,48 @@
+///
+
+import { start } from '../support/setup'
+import { isArray } from '../../src/utils/type-utils'
+
+describe('before_send', () => {
+ it('can sample and edit with before_send', () => {
+ start({})
+
+ cy.posthog().then((posthog) => {
+ let counter = 0
+ const og = posthog.config.before_send
+ // cypress tests rely on existing before_send function to capture events
+ // so we have to add it back in here
+ posthog.config.before_send = [
+ (cr) => {
+ if (cr.event === 'custom-event') {
+ counter++
+ if (counter === 2) {
+ return null
+ }
+ }
+ if (cr.event === '$autocapture') {
+ return {
+ ...cr,
+ event: 'redacted',
+ }
+ }
+ return cr
+ },
+ ...(isArray(og) ? og : [og]),
+ ]
+ })
+
+ cy.get('[data-cy-custom-event-button]').click()
+ cy.get('[data-cy-custom-event-button]').click()
+
+ cy.phCaptures().should('deep.equal', [
+ // before adding the new before sendfn
+ '$pageview',
+ 'redacted',
+ 'custom-event',
+ // second button click only has the redacted autocapture event
+ 'redacted',
+ // because the second custom-event is rejected
+ ])
+ })
+})
diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts
index 11a219bb2..fd85d005e 100644
--- a/cypress/support/commands.ts
+++ b/cypress/support/commands.ts
@@ -9,9 +9,10 @@ Cypress.Commands.add('posthogInit', (options) => {
cy.posthog().invoke('init', 'test_token', {
api_host: location.origin,
debug: true,
- _onCapture: (event, eventData) => {
- $captures.push(event)
- $fullCaptures.push(eventData)
+ before_send: (event) => {
+ $captures.push(event.event)
+ $fullCaptures.push(event)
+ return event
},
opt_out_useragent_filter: true,
...options,
diff --git a/playground/copy-autocapture/demo.html b/playground/copy-autocapture/demo.html
index 52f81cb5a..3afd12a59 100644
--- a/playground/copy-autocapture/demo.html
+++ b/playground/copy-autocapture/demo.html
@@ -10,13 +10,14 @@
loaded: function(posthog) {
posthog.identify('test')
},
- _onCapture: (event, eventData) => {
- if (event === '$copy_autocapture') {
- const selectionType = eventData.properties['$copy_type']
- const selectionText = eventData.properties['$selected_content']
+ before_send: (event) => {
+ if (event.event === '$copy_autocapture') {
+ const selectionType = event.properties['$copy_type']
+ const selectionText = event.properties['$selected_content']
document.getElementById('selection-type-outlet').innerText = selectionType
document.getElementById('selection-text-outlet').innerText = selectionText
}
+ return event
},
})
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__/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/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..20c2c14a3 100644
--- a/src/__tests__/posthog-core.test.ts
+++ b/src/__tests__/posthog-core.test.ts
@@ -45,10 +45,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 +71,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 +87,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]',
@@ -112,7 +112,7 @@ describe('posthog core', () => {
// arrange
const token = uuidv7()
mockReferrerGetter.mockReturnValue('https://referrer.example.com/some/path')
- const { posthog, onCapture } = setup({
+ const { posthog, beforeSendMock } = setup({
token,
persistence_name: token,
person_profiles: 'always',
@@ -122,7 +122,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')
@@ -140,7 +140,7 @@ describe('posthog core', () => {
})
posthog1.capture(eventName, eventProperties)
mockReferrerGetter.mockReturnValue('https://referrer2.example.com/some/path')
- const { posthog: posthog2, onCapture: onCapture2 } = setup({
+ const { posthog: posthog2, beforeSendMock } = setup({
token,
persistence_name: token,
})
@@ -153,7 +153,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')
@@ -171,7 +171,7 @@ describe('posthog core', () => {
})
posthog1.capture(eventName, eventProperties)
mockReferrerGetter.mockReturnValue('https://referrer2.example.com/some/path')
- const { posthog: posthog2, onCapture: onCapture2 } = setup({
+ const { posthog: posthog2, beforeSendMock: beforeSendMock2 } = setup({
token,
persistence_name: token,
})
@@ -184,7 +184,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')
@@ -195,7 +195,7 @@ describe('posthog core', () => {
// arrange
const token = uuidv7()
mockReferrerGetter.mockReturnValue('')
- const { posthog, onCapture } = setup({
+ const { posthog, beforeSendMock } = setup({
token,
persistence_name: token,
person_profiles: 'always',
@@ -205,7 +205,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')
@@ -218,7 +218,7 @@ describe('posthog core', () => {
// arrange
const token = uuidv7()
mockURLGetter.mockReturnValue('https://www.example.com/some/path')
- const { posthog, onCapture } = setup({
+ const { posthog, beforeSendMock } = setup({
token,
persistence_name: token,
})
@@ -227,15 +227,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({
+ const { posthog, beforeSendMock } = setup({
token,
persistence_name: token,
})
@@ -244,8 +244,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__/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/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/posthog-core.ts b/src/posthog-core.ts
index 69f94e94e..db362d2af 100644
--- a/src/posthog-core.ts
+++ b/src/posthog-core.ts
@@ -34,6 +34,7 @@ import {
Compression,
DecideResponse,
EarlyAccessFeatureCallback,
+ EventName,
IsFeatureEnabledOptions,
JsonType,
PostHogConfig,
@@ -57,6 +58,8 @@ import {
isEmptyObject,
isEmptyString,
isFunction,
+ isKnownUnsafeEditableEvent,
+ isNullish,
isNumber,
isObject,
isString,
@@ -180,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 => {
@@ -527,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))
}
@@ -785,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) {
@@ -868,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 = {
@@ -2058,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)
@@ -2159,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/types.ts b/src/types.ts
index 98695a8b6..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
diff --git a/src/utils/type-utils.ts b/src/utils/type-utils.ts
index 37e04ba64..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
@@ -89,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)