From bab074eae3fb91f37cb66cf8f364c7b4607ae507 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Mon, 14 Oct 2024 15:12:04 +0200 Subject: [PATCH 01/14] fix: sanitize set_once properties (#1462) --- src/__tests__/posthog-core.ts | 18 ++++++++++++++++++ src/posthog-core.ts | 6 +++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/__tests__/posthog-core.ts b/src/__tests__/posthog-core.ts index a0dea04e0..91fa5a800 100644 --- a/src/__tests__/posthog-core.ts +++ b/src/__tests__/posthog-core.ts @@ -500,6 +500,24 @@ describe('posthog core', () => { }) }) + it('calls sanitize_properties for $set_once', () => { + posthog = posthogWith( + { + api_host: 'https://custom.posthog.com', + sanitize_properties: (props, event_name) => ({ token: props.token, event_name, ...props }), + }, + overrides + ) + + posthog.persistence.get_initial_props = () => ({ initial: 'prop' }) + expect(posthog._calculate_set_once_properties({ key: 'prop' })).toEqual({ + event_name: '$set_once', + token: undefined, + initial: 'prop', + key: 'prop', + }) + }) + it('saves $snapshot data and token for $snapshot events', () => { posthog = posthogWith({}, overrides) diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 3dee01b08..6e06c2e43 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -988,7 +988,11 @@ export class PostHog { return dataSetOnce } // if we're an identified person, send initial params with every event - const setOnceProperties = extend({}, this.persistence.get_initial_props(), dataSetOnce || {}) + let setOnceProperties = extend({}, this.persistence.get_initial_props(), dataSetOnce || {}) + const sanitize_properties = this.config.sanitize_properties + if (sanitize_properties) { + setOnceProperties = sanitize_properties(setOnceProperties, '$set_once') + } if (isEmptyObject(setOnceProperties)) { return undefined } From abd11863f6772545cff988609b8e1837373e865e Mon Sep 17 00:00:00 2001 From: mariusandra Date: Mon, 14 Oct 2024 13:12:50 +0000 Subject: [PATCH 02/14] chore: Bump version to 1.167.1 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea90f02e8..a7b5e2f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.167.1 - 2024-10-14 + +- fix: sanitize set_once properties (#1462) + ## 1.167.0 - 2024-10-08 - feat(web experiments): Emit web_experiment_applied event and do not render experiments for bots (#1443) diff --git a/package.json b/package.json index 09de802fe..ef83fd7b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.167.0", + "version": "1.167.1", "description": "Posthog-js allows you to automatically capture usage and send events to PostHog.", "repository": "https://github.com/PostHog/posthog-js", "author": "hey@posthog.com", From 8e7e1ea6bc95199d9cc031ea975847cbc9209f55 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Mon, 14 Oct 2024 19:01:14 +0200 Subject: [PATCH 03/14] chore: refactor some cypress setup (#1467) --- cypress/e2e/opting-out.cy.ts | 30 ++++++++-------- cypress/e2e/session-recording.cy.ts | 14 ++++---- cypress/support/e2e.ts | 56 +++++++++-------------------- cypress/support/setup.ts | 2 +- 4 files changed, 40 insertions(+), 62 deletions(-) diff --git a/cypress/e2e/opting-out.cy.ts b/cypress/e2e/opting-out.cy.ts index 5bd6def94..e7bada65a 100644 --- a/cypress/e2e/opting-out.cy.ts +++ b/cypress/e2e/opting-out.cy.ts @@ -33,7 +33,7 @@ describe('opting out', () => { cy.get('[data-cy-input]').type('hello world! ') assertWhetherPostHogRequestsWereCalled({ - '@recorder': false, + '@recorder-script': false, '@decide': false, '@session-recording': false, }) @@ -51,7 +51,7 @@ describe('opting out', () => { cy.posthogInit({ opt_out_capturing_by_default: true }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': false, + '@recorder-script': false, '@decide': true, '@session-recording': false, }) @@ -69,7 +69,7 @@ describe('opting out', () => { cy.posthogInit({ disable_session_recording: true }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': false, + '@recorder-script': false, '@decide': true, '@session-recording': false, }) @@ -87,7 +87,7 @@ describe('opting out', () => { cy.posthogInit({ opt_out_capturing_by_default: true }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': false, + '@recorder-script': false, '@decide': true, '@session-recording': false, }) @@ -101,7 +101,7 @@ describe('opting out', () => { }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': true, + '@recorder-script': true, '@decide': true, // no call to session-recording yet }) @@ -117,7 +117,7 @@ describe('opting out', () => { cy.posthogInit({ disable_session_recording: true }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': false, + '@recorder-script': false, '@decide': true, '@session-recording': false, }) @@ -134,7 +134,7 @@ describe('opting out', () => { cy.posthog().invoke('startSessionRecording') assertWhetherPostHogRequestsWereCalled({ - '@recorder': true, + '@recorder-script': true, '@decide': true, // no call to session-recording yet }) @@ -163,7 +163,7 @@ describe('opting out', () => { }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': false, + '@recorder-script': false, '@decide': true, '@session-recording': false, }) @@ -177,7 +177,7 @@ describe('opting out', () => { }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': true, + '@recorder-script': true, '@decide': true, // no call to session-recording yet }) @@ -206,7 +206,7 @@ describe('opting out', () => { }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': false, + '@recorder-script': false, '@decide': true, '@session-recording': false, }) @@ -220,7 +220,7 @@ describe('opting out', () => { }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': true, + '@recorder-script': true, '@decide': true, // no call to session-recording yet }) @@ -262,7 +262,7 @@ describe('opting out', () => { }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': false, + '@recorder-script': false, '@decide': true, '@session-recording': false, }) @@ -276,7 +276,7 @@ describe('opting out', () => { }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': true, + '@recorder-script': true, '@decide': true, // no call to session-recording yet }) @@ -323,7 +323,7 @@ describe('opting out', () => { }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': false, + '@recorder-script': false, '@decide': true, '@session-recording': false, }) @@ -337,7 +337,7 @@ describe('opting out', () => { }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': true, + '@recorder-script': true, '@decide': true, // no call to session-recording yet }) diff --git a/cypress/e2e/session-recording.cy.ts b/cypress/e2e/session-recording.cy.ts index 4c3fa0d03..6ffa67f05 100644 --- a/cypress/e2e/session-recording.cy.ts +++ b/cypress/e2e/session-recording.cy.ts @@ -183,7 +183,7 @@ describe('Session recording', () => { }, }) - cy.wait('@recorder') + cy.wait('@recorder-script') cy.intercept({ url: 'https://example.com', times: 1 }, (req) => { req.reply({ @@ -281,7 +281,7 @@ describe('Session recording', () => { }, url: './playground/cypress', }) - cy.wait('@recorder') + cy.wait('@recorder-script') }) it('captures session events', () => { @@ -406,7 +406,7 @@ describe('Session recording', () => { cy.reload() cy.posthogInit({}) cy.wait('@decide') - cy.wait('@recorder') + cy.wait('@recorder-script') cy.get('body') .trigger('mousemove', { clientX: 200, clientY: 300 }) @@ -564,7 +564,7 @@ describe('Session recording', () => { }, url: './playground/cypress', }) - cy.wait('@recorder') + cy.wait('@recorder-script') }) it('does not capture when sampling is set to 0', () => { @@ -594,7 +594,7 @@ describe('Session recording', () => { }).as('decide') assertWhetherPostHogRequestsWereCalled({ - '@recorder': true, + '@recorder-script': true, '@decide': true, '@session-recording': false, }) @@ -606,7 +606,7 @@ describe('Session recording', () => { cy.posthog().invoke('startSessionRecording', { sampling: true }) assertWhetherPostHogRequestsWereCalled({ - '@recorder': true, + '@recorder-script': true, '@decide': true, // no call to session-recording yet }) @@ -637,7 +637,7 @@ describe('Session recording', () => { }, url: './playground/cypress', }) - cy.wait('@recorder') + cy.wait('@recorder-script') cy.get('[data-cy-input]').type('hello posthog!') diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index abff144f2..4754515dc 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -15,44 +15,22 @@ beforeEach(() => { cy.intercept('POST', '/ses/*', { status: 1 }).as('session-recording') cy.intercept('GET', '/surveys/*').as('surveys') - cy.readFile('dist/array.full.js').then((body) => { - cy.intercept('/static/array.full.js', { body }) - }) - - cy.readFile('dist/array.js').then((body) => { - cy.intercept('/static/array.js', { body }) - }) - - cy.readFile('dist/array.full.js.map').then((body) => { - cy.intercept('/static/array.full.js.map', { body }) - }) - - cy.readFile('dist/array.js.map').then((body) => { - cy.intercept('/static/array.js.map', { body }) - }) - - cy.readFile('dist/recorder.js').then((body) => { - cy.intercept('/static/recorder.js*', { body }).as('recorder') - cy.intercept('/static/recorder-v2.js*', { body }).as('recorderv2') - }) - - cy.readFile('dist/recorder.js.map').then((body) => { - cy.intercept('/static/recorder.js.map', { body }) - }) - - cy.readFile('dist/surveys.js').then((body) => { - cy.intercept('/static/surveys.js*', { body }) - }) - - cy.readFile('dist/surveys.js.map').then((body) => { - cy.intercept('/static/surveys.js.map', { body }) - }) - - cy.readFile('dist/exception-autocapture.js').then((body) => { - cy.intercept('/static/exception-autocapture.js*', { body }).as('exception-autocapture-script') - }) - - cy.readFile('dist/exception-autocapture.js.map').then((body) => { - cy.intercept('/static/exception-autocapture.js.map', { body }) + const lazyLoadedJSFiles = [ + 'array', + 'array.full', + 'recorder', + 'surveys', + 'exception-autocapture', + 'tracing-headers', + 'web-vitals', + ] + lazyLoadedJSFiles.forEach((key: string) => { + cy.readFile(`dist/${key}.js`).then((body) => { + cy.intercept(`/static/${key}.js*`, { body }).as(`${key}-script`) + }) + + cy.readFile(`dist/${key}.js.map`).then((body) => { + cy.intercept(`/static/${key}.js.map`, { body }) + }) }) }) diff --git a/cypress/support/setup.ts b/cypress/support/setup.ts index 655b3e070..ae9326a78 100644 --- a/cypress/support/setup.ts +++ b/cypress/support/setup.ts @@ -24,7 +24,7 @@ export const start = ({ // sometimes we have too many listeners in this test environment // that breaks the event emitter listeners in error tracking tests // we don't see the error in production, so it's fine to increase the limit here - EventEmitter.prototype._maxListeners = 100 + EventEmitter.prototype.setMaxListeners(100) const decideResponse = { editorParams: {}, From 1f30330fc37558970797b75128081d51dd7f82fc Mon Sep 17 00:00:00 2001 From: Neil Kakkar Date: Tue, 15 Oct 2024 10:19:53 +0100 Subject: [PATCH 04/14] fix(errors): Better define schema, align with python (#1460) --- cypress/e2e/error-tracking.cy.ts | 17 +- .../error-conversion.test.ts | 115 +++++---- .../exception-observer.test.ts | 31 ++- .../exception-autocapture/error-conversion.ts | 231 +++++++++++------- src/extensions/sentry-integration.ts | 2 + src/posthog-core.ts | 12 +- src/types.ts | 28 +-- src/utils/globals.ts | 8 +- 8 files changed, 278 insertions(+), 166 deletions(-) diff --git a/cypress/e2e/error-tracking.cy.ts b/cypress/e2e/error-tracking.cy.ts index 2b58ede97..94c685b11 100644 --- a/cypress/e2e/error-tracking.cy.ts +++ b/cypress/e2e/error-tracking.cy.ts @@ -17,12 +17,11 @@ describe('Exception capture', () => { cy.phCaptures({ full: true }).then((captures) => { expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$autocapture', '$exception']) expect(captures[2].event).to.be.eql('$exception') - expect(captures[2].properties.$exception_message).to.be.eql('wat even am I') - expect(captures[2].properties.$exception_type).to.be.eql('Error') expect(captures[2].properties.extra_prop).to.be.eql(2) expect(captures[2].properties.$exception_source).to.eql(undefined) expect(captures[2].properties.$exception_personURL).to.eql(undefined) - expect(captures[2].properties.$exception_stack_trace_raw).not.to.exist + expect(captures[2].properties.$exception_list[0].value).to.be.eql('wat even am I') + expect(captures[2].properties.$exception_list[0].type).to.be.eql('Error') }) }) @@ -51,11 +50,9 @@ describe('Exception capture', () => { cy.phCaptures({ full: true }).then((captures) => { expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$autocapture', '$exception']) expect(captures[2].event).to.be.eql('$exception') - expect(captures[2].properties.$exception_message).to.be.eql('This is an error') - expect(captures[2].properties.$exception_type).to.be.eql('Error') - expect(captures[2].properties.$exception_source).to.match( - /http:\/\/localhost:\d+\/playground\/cypress\// - ) + expect(captures[2].properties.$exception_list[0].value).to.be.eql('This is an error') + expect(captures[2].properties.$exception_list[0].type).to.be.eql('Error') + expect(captures[2].properties.$exception_personURL).to.match( /http:\/\/localhost:\d+\/project\/test_token\/person\/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ ) @@ -69,8 +66,8 @@ describe('Exception capture', () => { cy.wait(1500) cy.phCaptures({ full: true }).then((captures) => { - expect(captures[2].properties.$exception_message).to.be.eql('wat even am I') - expect(captures[2].properties.$exception_stack_trace_raw).to.exist + expect(captures[2].properties.$exception_list).to.exist + expect(captures[2].properties.$exception_list[0].value).to.be.eql('wat even am I') }) }) }) diff --git a/src/__tests__/extensions/exception-autocapture/error-conversion.test.ts b/src/__tests__/extensions/exception-autocapture/error-conversion.test.ts index 181ff01c4..16c25c83c 100644 --- a/src/__tests__/extensions/exception-autocapture/error-conversion.test.ts +++ b/src/__tests__/extensions/exception-autocapture/error-conversion.test.ts @@ -1,13 +1,13 @@ /* eslint-disable compat/compat */ import { + ErrorProperties, errorToProperties, unhandledRejectionToProperties, } from '../../../extensions/exception-autocapture/error-conversion' import { isNull } from '../../../utils/type-utils' import { expect } from '@jest/globals' -import { ErrorProperties } from '../../../types' // ugh, jest // can't reference PromiseRejectionEvent to construct it 🤷 @@ -34,30 +34,42 @@ export class PromiseRejectionEvent extends Event { describe('Error conversion', () => { it('should convert a string to an error', () => { const expected: ErrorProperties = { - $exception_type: 'InternalError', - $exception_message: 'but somehow still a string', - $exception_is_synthetic: true, $exception_level: 'error', + $exception_list: [ + { + type: 'InternalError', + value: 'but somehow still a string', + mechanism: { synthetic: true, handled: true }, + }, + ], } expect(errorToProperties(['Uncaught exception: InternalError: but somehow still a string'])).toEqual(expected) }) it('should convert a plain object to an error', () => { const expected: ErrorProperties = { - $exception_type: 'Error', - $exception_message: 'Non-Error exception captured with keys: foo, string', - $exception_is_synthetic: true, $exception_level: 'error', + $exception_list: [ + { + type: 'Error', + value: 'Non-Error exception captured with keys: foo, string', + mechanism: { synthetic: true, handled: true }, + }, + ], } expect(errorToProperties([{ string: 'candidate', foo: 'bar' } as unknown as Event])).toEqual(expected) }) it('should convert a plain Event to an error', () => { const expected: ErrorProperties = { - $exception_type: 'MouseEvent', - $exception_message: 'Non-Error exception captured with keys: isTrusted', - $exception_is_synthetic: true, $exception_level: 'error', + $exception_list: [ + { + type: 'MouseEvent', + value: 'Non-Error exception captured with keys: isTrusted', + mechanism: { synthetic: true, handled: true }, + }, + ], } const event = new MouseEvent('click', { bubbles: true, cancelable: true, composed: true }) expect(errorToProperties([event])).toEqual(expected) @@ -71,13 +83,16 @@ describe('Error conversion', () => { throw new Error("this mustn't be null") } - expect(Object.keys(errorProperties)).toHaveLength(4) - expect(errorProperties.$exception_type).toEqual('Error') - expect(errorProperties.$exception_message).toEqual('oh no an error has happened') + expect(Object.keys(errorProperties)).toHaveLength(2) expect(errorProperties.$exception_level).toEqual('error') // the stack trace changes between runs, so we just check that it's there - expect(errorProperties.$exception_stack_trace_raw).toBeDefined() - expect(errorProperties.$exception_stack_trace_raw).toContain('{"filename') + expect(errorProperties.$exception_list).toBeDefined() + expect(errorProperties.$exception_list[0].type).toEqual('Error') + expect(errorProperties.$exception_list[0].value).toEqual('oh no an error has happened') + expect(errorProperties.$exception_list[0].stacktrace.frames[0].in_app).toEqual(true) + expect(errorProperties.$exception_list[0].stacktrace.frames[0].filename).toBeDefined() + expect(errorProperties.$exception_list[0].mechanism.synthetic).toEqual(false) + expect(errorProperties.$exception_list[0].mechanism.handled).toEqual(true) }) class FakeDomError { @@ -87,9 +102,14 @@ describe('Error conversion', () => { it('should convert a DOM Error to an error', () => { const expected: ErrorProperties = { - $exception_type: 'DOMError', - $exception_message: 'click: foo', $exception_level: 'error', + $exception_list: [ + { + type: 'DOMError', + value: 'click: foo', + mechanism: { synthetic: true, handled: true }, + }, + ], } const event = new FakeDomError('click', 'foo') expect(errorToProperties([event as unknown as Event])).toEqual(expected) @@ -103,13 +123,15 @@ describe('Error conversion', () => { throw new Error("this mustn't be null") } - expect(Object.keys(errorProperties)).toHaveLength(5) - expect(errorProperties.$exception_type).toEqual('dom-exception') - expect(errorProperties.$exception_message).toEqual('oh no disaster') + expect(Object.keys(errorProperties)).toHaveLength(3) + expect(errorProperties.$exception_list[0].type).toEqual('dom-exception') + expect(errorProperties.$exception_list[0].value).toEqual('oh no disaster') + expect(errorProperties.$exception_DOMException_code).toEqual('0') expect(errorProperties.$exception_level).toEqual('error') // the stack trace changes between runs, so we just check that it's there - expect(errorProperties.$exception_stack_trace_raw).toBeDefined() - expect(errorProperties.$exception_stack_trace_raw).toContain('{"filename') + expect(errorProperties.$exception_list).toBeDefined() + expect(errorProperties.$exception_list[0].stacktrace.frames[0].in_app).toEqual(true) + expect(errorProperties.$exception_list[0].stacktrace.frames[0].filename).toBeDefined() }) it('should convert an error event to an error', () => { @@ -120,24 +142,28 @@ describe('Error conversion', () => { throw new Error("this mustn't be null") } - expect(Object.keys(errorProperties)).toHaveLength(4) - expect(errorProperties.$exception_type).toEqual('Error') - expect(errorProperties.$exception_message).toEqual('the real error is hidden inside') + expect(Object.keys(errorProperties)).toHaveLength(2) + expect(errorProperties.$exception_list[0].type).toEqual('Error') + expect(errorProperties.$exception_list[0].value).toEqual('the real error is hidden inside') expect(errorProperties.$exception_level).toEqual('error') // the stack trace changes between runs, so we just check that it's there - expect(errorProperties.$exception_stack_trace_raw).toBeDefined() - expect(errorProperties.$exception_stack_trace_raw).toContain('{"filename') + expect(errorProperties.$exception_list).toBeDefined() + expect(errorProperties.$exception_list[0].stacktrace.frames[0].in_app).toEqual(true) + expect(errorProperties.$exception_list[0].stacktrace.frames[0].filename).toBeDefined() + expect(errorProperties.$exception_list[0].mechanism.synthetic).toEqual(false) + expect(errorProperties.$exception_list[0].mechanism.handled).toEqual(true) }) it('can convert source, lineno, colno', () => { const expected: ErrorProperties = { - $exception_colno: 200, - $exception_is_synthetic: true, - $exception_lineno: 12, - $exception_message: 'string candidate', - $exception_source: 'a source', - $exception_type: 'Error', $exception_level: 'error', + $exception_list: [ + { + type: 'Error', + value: 'string candidate', + mechanism: { synthetic: true, handled: true }, + }, + ], } expect(errorToProperties(['string candidate', 'a source', 12, 200])).toEqual(expected) }) @@ -152,14 +178,16 @@ describe('Error conversion', () => { const errorProperties: ErrorProperties = unhandledRejectionToProperties([ ce as unknown as PromiseRejectionEvent, ]) - expect(Object.keys(errorProperties)).toHaveLength(5) - expect(errorProperties.$exception_type).toEqual('UnhandledRejection') - expect(errorProperties.$exception_message).toEqual('a wrapped rejection event') - expect(errorProperties.$exception_handled).toEqual(false) + expect(Object.keys(errorProperties)).toHaveLength(2) + expect(errorProperties.$exception_list[0].type).toEqual('UnhandledRejection') + expect(errorProperties.$exception_list[0].value).toEqual('a wrapped rejection event') expect(errorProperties.$exception_level).toEqual('error') // the stack trace changes between runs, so we just check that it's there - expect(errorProperties.$exception_stack_trace_raw).toBeDefined() - expect(errorProperties.$exception_stack_trace_raw).toContain('{"filename') + expect(errorProperties.$exception_list).toBeDefined() + expect(errorProperties.$exception_list[0].stacktrace.frames[0].in_app).toEqual(true) + expect(errorProperties.$exception_list[0].stacktrace.frames[0].filename).toBeDefined() + expect(errorProperties.$exception_list[0].mechanism.synthetic).toEqual(false) + expect(errorProperties.$exception_list[0].mechanism.handled).toEqual(false) }) it('should convert unhandled promise rejection', () => { @@ -170,12 +198,13 @@ describe('Error conversion', () => { const errorProperties: ErrorProperties = unhandledRejectionToProperties([ pre as unknown as PromiseRejectionEvent, ]) - expect(Object.keys(errorProperties)).toHaveLength(4) - expect(errorProperties.$exception_type).toEqual('UnhandledRejection') - expect(errorProperties.$exception_message).toEqual( + expect(Object.keys(errorProperties)).toHaveLength(2) + expect(errorProperties.$exception_list[0].type).toEqual('UnhandledRejection') + expect(errorProperties.$exception_list[0].value).toEqual( 'Non-Error promise rejection captured with value: My house is on fire' ) - expect(errorProperties.$exception_handled).toEqual(false) expect(errorProperties.$exception_level).toEqual('error') + expect(errorProperties.$exception_list[0].mechanism.synthetic).toEqual(false) + expect(errorProperties.$exception_list[0].mechanism.handled).toEqual(false) }) }) diff --git a/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts b/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts index 42acf7292..492bae78b 100644 --- a/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts +++ b/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts @@ -97,9 +97,15 @@ describe('Exception Observer', () => { expect(singleCall[0]).toBe('$exception') expect(singleCall[1]).toMatchObject({ properties: { - $exception_message: 'test error', - $exception_type: 'Error', $exception_personURL: expect.any(String), + $exception_list: [ + { + type: 'Error', + value: 'test error', + stacktrace: { frames: expect.any(Array) }, + mechanism: { synthetic: false, handled: true }, + }, + ], }, }) }) @@ -120,9 +126,15 @@ describe('Exception Observer', () => { expect(singleCall[0]).toBe('$exception') expect(singleCall[1]).toMatchObject({ properties: { - $exception_message: 'test error', - $exception_type: 'UnhandledRejection', $exception_personURL: expect.any(String), + $exception_list: [ + { + type: 'UnhandledRejection', + value: 'test error', + stacktrace: { frames: expect.any(Array) }, + mechanism: { synthetic: false, handled: false }, + }, + ], }, }) }) @@ -137,10 +149,15 @@ describe('Exception Observer', () => { expect(request.data).toMatchObject({ event: '$exception', properties: { - $exception_message: 'test error', - $exception_type: 'Error', $exception_personURL: expect.any(String), - $exception_stack_trace_raw: expect.any(String), + $exception_list: [ + { + type: 'Error', + value: 'test error', + stacktrace: { frames: expect.any(Array) }, + mechanism: { synthetic: false, handled: true }, + }, + ], }, }) expect(request.batchKey).toBe('exceptionEvent') diff --git a/src/extensions/exception-autocapture/error-conversion.ts b/src/extensions/exception-autocapture/error-conversion.ts index 26398a9b5..9d2c1e19c 100644 --- a/src/extensions/exception-autocapture/error-conversion.ts +++ b/src/extensions/exception-autocapture/error-conversion.ts @@ -11,7 +11,44 @@ import { import { defaultStackParser, StackFrame } from './stack-trace' import { isEmptyString, isNumber, isString, isUndefined } from '../../utils/type-utils' -import { ErrorEventArgs, ErrorProperties, SeverityLevel, severityLevels } from '../../types' +import { ErrorEventArgs, ErrorMetadata, SeverityLevel, severityLevels } from '../../types' + +export interface ErrorProperties { + $exception_list: Exception[] + $exception_level?: SeverityLevel + $exception_DOMException_code?: string + $exception_personURL?: string +} + +export interface Exception { + type?: string + value?: string + mechanism?: { + /** + * In theory, whether or not the exception has been handled by the user. In practice, whether or not we see it before + * it hits the global error/rejection handlers, whether through explicit handling by the user or auto instrumentation. + */ + handled?: boolean + type?: string + source?: string + /** + * True when `captureException` is called with anything other than an instance of `Error` (or, in the case of browser, + * an instance of `ErrorEvent`, `DOMError`, or `DOMException`). causing us to create a synthetic error in an attempt + * to recreate the stacktrace. + */ + synthetic?: boolean + } + module?: string + thread_id?: number + stacktrace?: { + frames?: StackFrame[] + } +} + +export interface ErrorConversions { + errorToProperties: (args: ErrorEventArgs, metadata?: ErrorMetadata) => ErrorProperties + unhandledRejectionToProperties: (args: [ev: PromiseRejectionEvent]) => ErrorProperties +} /** * based on the very wonderful MIT licensed Sentry SDK @@ -53,21 +90,58 @@ export function parseStackFrames(ex: Error & { framesToPop?: number; stacktrace? return [] } -function errorPropertiesFromError(error: Error): ErrorProperties { +function errorPropertiesFromError(error: Error, metadata?: ErrorMetadata): ErrorProperties { const frames = parseStackFrames(error) + const handled = metadata?.handled ?? true + const synthetic = metadata?.synthetic ?? false + + const exceptionType = metadata?.overrideExceptionType ? metadata.overrideExceptionType : error.name + const exceptionMessage = metadata?.overrideExceptionMessage ? metadata.overrideExceptionMessage : error.message + return { - $exception_type: error.name, - $exception_message: error.message, - $exception_stack_trace_raw: JSON.stringify(frames), + $exception_list: [ + { + type: exceptionType, + value: exceptionMessage, + stacktrace: { + frames, + }, + mechanism: { + handled, + synthetic, + }, + }, + ], $exception_level: 'error', } } -function errorPropertiesFromString(candidate: string): ErrorProperties { +function errorPropertiesFromString(candidate: string, metadata?: ErrorMetadata): ErrorProperties { + // Defaults for metadata are based on what the error candidate is. + const handled = metadata?.handled ?? true + const synthetic = metadata?.synthetic ?? true + + const exceptionType = metadata?.overrideExceptionType + ? metadata.overrideExceptionType + : metadata?.defaultExceptionType ?? 'Error' + const exceptionMessage = metadata?.overrideExceptionMessage + ? metadata.overrideExceptionMessage + : candidate + ? candidate + : metadata?.defaultExceptionMessage + return { - $exception_type: 'Error', - $exception_message: candidate, + $exception_list: [ + { + type: exceptionType, + value: exceptionMessage, + mechanism: { + handled, + synthetic, + }, + }, + ], $exception_level: 'error', } } @@ -103,35 +177,41 @@ function isSeverityLevel(x: unknown): x is SeverityLevel { return isString(x) && !isEmptyString(x) && severityLevels.indexOf(x as SeverityLevel) >= 0 } -function errorPropertiesFromObject(candidate: Record): ErrorProperties { +function errorPropertiesFromObject(candidate: Record, metadata?: ErrorMetadata): ErrorProperties { + // Defaults for metadata are based on what the error candidate is. + const handled = metadata?.handled ?? true + const synthetic = metadata?.synthetic ?? true + + const exceptionType = metadata?.overrideExceptionType + ? metadata.overrideExceptionType + : isEvent(candidate) + ? candidate.constructor.name + : 'Error' + const exceptionMessage = metadata?.overrideExceptionMessage + ? metadata.overrideExceptionMessage + : `Non-Error ${'exception'} captured with keys: ${extractExceptionKeysForMessage(candidate)}` + return { - $exception_type: isEvent(candidate) ? candidate.constructor.name : 'Error', - $exception_message: `Non-Error ${'exception'} captured with keys: ${extractExceptionKeysForMessage(candidate)}`, + $exception_list: [ + { + type: exceptionType, + value: exceptionMessage, + mechanism: { + handled, + synthetic, + }, + }, + ], $exception_level: isSeverityLevel(candidate.level) ? candidate.level : 'error', } } -export function errorToProperties([event, source, lineno, colno, error]: ErrorEventArgs): ErrorProperties { - // some properties are not optional but, it's useful to start off without them enforced - let errorProperties: Omit & { - $exception_type?: string - $exception_message?: string - $exception_level?: string - } = {} - - if (isUndefined(error) && isString(event)) { - let name = 'Error' - let message = event - const groups = event.match(ERROR_TYPES_PATTERN) - if (groups) { - name = groups[1] - message = groups[2] - } - errorProperties = { - $exception_type: name, - $exception_message: message, - } - } +export function errorToProperties( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [event, _, __, ___, error]: ErrorEventArgs, + metadata?: ErrorMetadata +): ErrorProperties { + let errorProperties: ErrorProperties = { $exception_list: [] } const candidate = error || event @@ -142,48 +222,45 @@ export function errorToProperties([event, source, lineno, colno, error]: ErrorEv const domException = candidate as unknown as DOMException if (isErrorWithStack(candidate)) { - errorProperties = errorPropertiesFromError(candidate as Error) + errorProperties = errorPropertiesFromError(candidate as Error, metadata) } else { const name = domException.name || (isDOMError(domException) ? 'DOMError' : 'DOMException') const message = domException.message ? `${name}: ${domException.message}` : name - errorProperties = errorPropertiesFromString(message) - errorProperties.$exception_type = isDOMError(domException) ? 'DOMError' : 'DOMException' - errorProperties.$exception_message = errorProperties.$exception_message || message + const exceptionType = isDOMError(domException) ? 'DOMError' : 'DOMException' + errorProperties = errorPropertiesFromString(message, { + ...metadata, + overrideExceptionType: exceptionType, + defaultExceptionMessage: message, + }) } if ('code' in domException) { errorProperties['$exception_DOMException_code'] = `${domException.code}` } + return errorProperties } else if (isErrorEvent(candidate as ErrorEvent) && (candidate as ErrorEvent).error) { - errorProperties = errorPropertiesFromError((candidate as ErrorEvent).error as Error) + return errorPropertiesFromError((candidate as ErrorEvent).error as Error, metadata) } else if (isError(candidate)) { - errorProperties = errorPropertiesFromError(candidate) + return errorPropertiesFromError(candidate, metadata) } else if (isPlainObject(candidate) || isEvent(candidate)) { // group these by using the keys available on the object const objectException = candidate as Record - errorProperties = errorPropertiesFromObject(objectException) - errorProperties.$exception_is_synthetic = true - } else { - // If none of previous checks were valid, then it must be a string - errorProperties.$exception_type = errorProperties.$exception_type || 'Error' - errorProperties.$exception_message = errorProperties.$exception_message || candidate - errorProperties.$exception_is_synthetic = true - } + return errorPropertiesFromObject(objectException) + } else if (isUndefined(error) && isString(event)) { + let name = 'Error' + let message = event + const groups = event.match(ERROR_TYPES_PATTERN) + if (groups) { + name = groups[1] + message = groups[2] + } - return { - ...errorProperties, - // now we make sure the mandatory fields that were made optional are present - $exception_type: errorProperties.$exception_type || 'UnknownErrorType', - $exception_message: errorProperties.$exception_message || '', - $exception_level: isSeverityLevel(errorProperties.$exception_level) - ? errorProperties.$exception_level - : 'error', - ...(source - ? { - $exception_source: source, // TODO get this from URL if not present - } - : {}), - ...(lineno ? { $exception_lineno: lineno } : {}), - ...(colno ? { $exception_colno: colno } : {}), + return errorPropertiesFromString(message, { + ...metadata, + overrideExceptionType: name, + defaultExceptionMessage: message, + }) + } else { + return errorPropertiesFromString(candidate as string, metadata) } } @@ -208,29 +285,17 @@ export function unhandledRejectionToProperties([ev]: [ev: PromiseRejectionEvent] // no-empty } - // some properties are not optional but, it's useful to start off without them enforced - let errorProperties: Omit & { - $exception_type?: string - $exception_message?: string - $exception_level?: string - } = {} if (isPrimitive(error)) { - errorProperties = { - $exception_message: `Non-Error promise rejection captured with value: ${String(error)}`, - } + return errorPropertiesFromString(`Non-Error promise rejection captured with value: ${String(error)}`, { + handled: false, + synthetic: false, + overrideExceptionType: 'UnhandledRejection', + }) } else { - errorProperties = errorToProperties([error as string | Event]) - } - errorProperties.$exception_handled = false - - return { - ...errorProperties, - // now we make sure the mandatory fields that were made optional are present - $exception_type: (errorProperties.$exception_type = 'UnhandledRejection'), - $exception_message: (errorProperties.$exception_message = - errorProperties.$exception_message || (ev as any).reason || String(error)), - $exception_level: isSeverityLevel(errorProperties.$exception_level) - ? errorProperties.$exception_level - : 'error', + return errorToProperties([error as string | Event], { + handled: false, + overrideExceptionType: 'UnhandledRejection', + defaultExceptionMessage: (ev as any).reason || String(error), + }) } } diff --git a/src/extensions/sentry-integration.ts b/src/extensions/sentry-integration.ts index 4930fdfd0..b3864365d 100644 --- a/src/extensions/sentry-integration.ts +++ b/src/extensions/sentry-integration.ts @@ -97,6 +97,7 @@ export function createEventProcessor( // added manually to avoid any dependency on the lazily loaded content $exception_message: any $exception_type: any + $exception_list: any $exception_personURL: string $exception_level: SeverityLevel $level: SeverityLevel @@ -106,6 +107,7 @@ export function createEventProcessor( $exception_type: exceptions[0]?.type, $exception_personURL: personUrl, $exception_level: event.level, + $exception_list: exceptions, // Sentry Exception Properties $sentry_event_id: event.event_id, $sentry_exception: event.exception, diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 6e06c2e43..e5fd4bf1c 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -1835,9 +1835,17 @@ export class PostHog { error, ]) : { - $exception_type: error.name, - $exception_message: error.message, $exception_level: 'error', + $exception_list: [ + { + type: error.name, + value: error.message, + mechanism: { + handled: true, + synthetic: false, + }, + }, + ], ...additionalProperties, } diff --git a/src/types.ts b/src/types.ts index 9133fad89..e18ced49f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -570,27 +570,17 @@ export type ErrorEventArgs = [ error?: Error | undefined ] +export type ErrorMetadata = { + handled?: boolean + synthetic?: boolean + overrideExceptionType?: string + overrideExceptionMessage?: string + defaultExceptionType?: string + defaultExceptionMessage?: string +} + // levels originally copied from Sentry to work with the sentry integration // and to avoid relying on a frequently changing @sentry/types dependency // but provided as an array of literal types, so we can constrain the level below export const severityLevels = ['fatal', 'error', 'warning', 'log', 'info', 'debug'] as const export declare type SeverityLevel = typeof severityLevels[number] - -export interface ErrorProperties { - $exception_type: string - $exception_message: string - $exception_level: SeverityLevel - $exception_source?: string - $exception_lineno?: number - $exception_colno?: number - $exception_DOMException_code?: string - $exception_is_synthetic?: boolean - $exception_stack_trace_raw?: string - $exception_handled?: boolean - $exception_personURL?: string -} - -export interface ErrorConversions { - errorToProperties: (args: ErrorEventArgs) => ErrorProperties - unhandledRejectionToProperties: (args: [ev: PromiseRejectionEvent]) => ErrorProperties -} diff --git a/src/utils/globals.ts b/src/utils/globals.ts index 5400083d4..d1fb123f5 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -1,6 +1,7 @@ +import { ErrorProperties } from '../extensions/exception-autocapture/error-conversion' import type { PostHog } from '../posthog-core' import { SessionIdManager } from '../sessionid' -import { ErrorEventArgs, ErrorProperties, Properties } from '../types' +import { ErrorEventArgs, ErrorMetadata, Properties } from '../types' /* * Global helpers to protect access to browser globals in a way that is safer for different targets @@ -37,7 +38,10 @@ interface PostHogExtensions { loadSiteApp?: (posthog: PostHog, appUrl: string, callback: (error?: string | Event, event?: Event) => void) => void - parseErrorAsProperties?: ([event, source, lineno, colno, error]: ErrorEventArgs) => ErrorProperties + parseErrorAsProperties?: ( + [event, source, lineno, colno, error]: ErrorEventArgs, + metadata?: ErrorMetadata + ) => ErrorProperties errorWrappingFunctions?: { wrapOnError: (captureFn: (props: Properties) => void) => () => void wrapUnhandledRejection: (captureFn: (props: Properties) => void) => () => void From f55cf047b24d853d5dada6e5961da0d5f22178f9 Mon Sep 17 00:00:00 2001 From: neilkakkar Date: Tue, 15 Oct 2024 09:20:36 +0000 Subject: [PATCH 05/14] chore: Bump version to 1.168.0 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b5e2f45..3d5de4f23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.168.0 - 2024-10-15 + +- fix(errors): Better define schema, align with python (#1460) +- chore: refactor some cypress setup (#1467) + ## 1.167.1 - 2024-10-14 - fix: sanitize set_once properties (#1462) diff --git a/package.json b/package.json index ef83fd7b3..8f705c77d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.167.1", + "version": "1.168.0", "description": "Posthog-js allows you to automatically capture usage and send events to PostHog.", "repository": "https://github.com/PostHog/posthog-js", "author": "hey@posthog.com", From f6d62d807bf3c6b92ac6cd7b3e7197de0c027918 Mon Sep 17 00:00:00 2001 From: David Newell Date: Tue, 15 Oct 2024 11:14:30 +0100 Subject: [PATCH 06/14] chore: improve exception autocapture (#1466) --- .../exception-autocapture/error-conversion.ts | 105 +++++++++++------- .../exception-autocapture/stack-trace.ts | 104 +++++++++++------ 2 files changed, 133 insertions(+), 76 deletions(-) diff --git a/src/extensions/exception-autocapture/error-conversion.ts b/src/extensions/exception-autocapture/error-conversion.ts index 9d2c1e19c..f4997690d 100644 --- a/src/extensions/exception-autocapture/error-conversion.ts +++ b/src/extensions/exception-autocapture/error-conversion.ts @@ -10,7 +10,7 @@ import { } from './type-checking' import { defaultStackParser, StackFrame } from './stack-trace' -import { isEmptyString, isNumber, isString, isUndefined } from '../../utils/type-utils' +import { isEmptyString, isString, isUndefined } from '../../utils/type-utils' import { ErrorEventArgs, ErrorMetadata, SeverityLevel, severityLevels } from '../../types' export interface ErrorProperties { @@ -49,7 +49,6 @@ export interface ErrorConversions { errorToProperties: (args: ErrorEventArgs, metadata?: ErrorMetadata) => ErrorProperties unhandledRejectionToProperties: (args: [ev: PromiseRejectionEvent]) => ErrorProperties } - /** * based on the very wonderful MIT licensed Sentry SDK */ @@ -57,32 +56,16 @@ export interface ErrorConversions { const ERROR_TYPES_PATTERN = /^(?:[Uu]ncaught (?:exception: )?)?(?:((?:Eval|Internal|Range|Reference|Syntax|Type|URI|)Error): )?(.*)$/i -const reactMinifiedRegexp = /Minified React error #\d+;/i - -function getPopSize(ex: Error & { framesToPop?: number }): number { - if (ex) { - if (isNumber(ex.framesToPop)) { - return ex.framesToPop - } - - if (reactMinifiedRegexp.test(ex.message)) { - return 1 - } - } - - return 0 -} - export function parseStackFrames(ex: Error & { framesToPop?: number; stacktrace?: string }): StackFrame[] { // Access and store the stacktrace property before doing ANYTHING // else to it because Opera is not very good at providing it // reliably in other circumstances. const stacktrace = ex.stacktrace || ex.stack || '' - const popSize = getPopSize(ex) + const skipLines = getSkipFirstStackStringLines(ex) try { - return defaultStackParser(stacktrace, popSize) + return defaultStackParser(stacktrace, skipLines) } catch { // no-empty } @@ -90,6 +73,21 @@ export function parseStackFrames(ex: Error & { framesToPop?: number; stacktrace? return [] } +const reactMinifiedRegexp = /Minified React error #\d+;/i + +/** + * Certain known React errors contain links that would be falsely + * parsed as frames. This function check for these errors and + * returns number of the stack string lines to skip. + */ +function getSkipFirstStackStringLines(ex: Error): number { + if (ex && reactMinifiedRegexp.test(ex.message)) { + return 1 + } + + return 0 +} + function errorPropertiesFromError(error: Error, metadata?: ErrorMetadata): ErrorProperties { const frames = parseStackFrames(error) @@ -97,7 +95,9 @@ function errorPropertiesFromError(error: Error, metadata?: ErrorMetadata): Error const synthetic = metadata?.synthetic ?? false const exceptionType = metadata?.overrideExceptionType ? metadata.overrideExceptionType : error.name - const exceptionMessage = metadata?.overrideExceptionMessage ? metadata.overrideExceptionMessage : error.message + const exceptionMessage = metadata?.overrideExceptionMessage + ? metadata.overrideExceptionMessage + : extractMessage(error) return { $exception_list: [ @@ -117,6 +117,21 @@ function errorPropertiesFromError(error: Error, metadata?: ErrorMetadata): Error } } +/** + * There are cases where stacktrace.message is an Event object + * https://github.com/getsentry/sentry-javascript/issues/1949 + * In this specific case we try to extract stacktrace.message.error.message + */ +export function extractMessage(err: Error & { message: { error?: Error } }): string { + const message = err.message + + if (message.error && typeof message.error.message === 'string') { + return message.error.message + } + + return message +} + function errorPropertiesFromString(candidate: string, metadata?: ErrorMetadata): ErrorProperties { // Defaults for metadata are based on what the error candidate is. const handled = metadata?.handled ?? true @@ -265,37 +280,49 @@ export function errorToProperties( } export function unhandledRejectionToProperties([ev]: [ev: PromiseRejectionEvent]): ErrorProperties { + const error = getUnhandledRejectionError(ev) + + if (isPrimitive(error)) { + return errorPropertiesFromString(`Non-Error promise rejection captured with value: ${String(error)}`, { + handled: false, + synthetic: false, + overrideExceptionType: 'UnhandledRejection', + }) + } + + return errorToProperties([error as string | Event], { + handled: false, + overrideExceptionType: 'UnhandledRejection', + defaultExceptionMessage: String(error), + }) +} + +function getUnhandledRejectionError(error: unknown): unknown { + if (isPrimitive(error)) { + return error + } + // dig the object of the rejection out of known event types - let error: unknown = ev try { + type ErrorWithReason = { reason: unknown } // PromiseRejectionEvents store the object of the rejection under 'reason' // see https://developer.mozilla.org/en-US/docs/Web/API/PromiseRejectionEvent - if ('reason' in ev) { - error = ev.reason + if ('reason' in (error as ErrorWithReason)) { + return (error as ErrorWithReason).reason } + + type CustomEventWithDetail = { detail: { reason: unknown } } // something, somewhere, (likely a browser extension) effectively casts PromiseRejectionEvents // to CustomEvents, moving the `promise` and `reason` attributes of the PRE into // the CustomEvent's `detail` attribute, since they're not part of CustomEvent's spec // see https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent and // https://github.com/getsentry/sentry-javascript/issues/2380 - else if ('detail' in ev && 'reason' in (ev as any).detail) { - error = (ev as any).detail.reason + if ('detail' in (error as CustomEventWithDetail) && 'reason' in (error as CustomEventWithDetail).detail) { + return (error as CustomEventWithDetail).detail.reason } } catch { // no-empty } - if (isPrimitive(error)) { - return errorPropertiesFromString(`Non-Error promise rejection captured with value: ${String(error)}`, { - handled: false, - synthetic: false, - overrideExceptionType: 'UnhandledRejection', - }) - } else { - return errorToProperties([error as string | Event], { - handled: false, - overrideExceptionType: 'UnhandledRejection', - defaultExceptionMessage: (ev as any).reason || String(error), - }) - } + return error } diff --git a/src/extensions/exception-autocapture/stack-trace.ts b/src/extensions/exception-autocapture/stack-trace.ts index d39f02a6e..f09127713 100644 --- a/src/extensions/exception-autocapture/stack-trace.ts +++ b/src/extensions/exception-autocapture/stack-trace.ts @@ -28,16 +28,9 @@ import { isUndefined } from '../../utils/type-utils' -const WEBPACK_ERROR_REGEXP = /\(error: (.*)\)/ -const STACKTRACE_FRAME_LIMIT = 50 - -const UNKNOWN_FUNCTION = '?' - -const OPERA10_PRIORITY = 10 -const OPERA11_PRIORITY = 20 -const CHROME_PRIORITY = 30 -const WINJS_PRIORITY = 40 -const GECKO_PRIORITY = 50 +export type StackParser = (stack: string, skipFirstLines?: number) => StackFrame[] +export type StackLineParserFn = (line: string) => StackFrame | undefined +export type StackLineParser = [number, StackLineParserFn] export interface StackFrame { filename?: string @@ -57,10 +50,22 @@ export interface StackFrame { debug_id?: string } +const WEBPACK_ERROR_REGEXP = /\(error: (.*)\)/ +const STRIP_FRAME_REGEXP = /captureException/ +const STACKTRACE_FRAME_LIMIT = 50 + +const UNKNOWN_FUNCTION = '?' + +const OPERA10_PRIORITY = 10 +const OPERA11_PRIORITY = 20 +const CHROME_PRIORITY = 30 +const WINJS_PRIORITY = 40 +const GECKO_PRIORITY = 50 + function createFrame(filename: string, func: string, lineno?: number, colno?: number): StackFrame { const frame: StackFrame = { filename, - function: func, + function: func === '' ? UNKNOWN_FUNCTION : func, in_app: true, // All browser frames are considered in_app } @@ -75,23 +80,36 @@ function createFrame(filename: string, func: string, lineno?: number, colno?: nu return frame } -export type StackParser = (stack: string, skipFirst?: number) => StackFrame[] -export type StackLineParserFn = (line: string) => StackFrame | undefined -export type StackLineParser = [number, StackLineParserFn] +// This regex matches frames that have no function name (ie. are at the top level of a module). +// For example "at http://localhost:5000//script.js:1:126" +// Frames _with_ function names usually look as follows: "at commitLayoutEffects (react-dom.development.js:23426:1)" +const chromeRegexNoFnName = /^\s*at (\S+?)(?::(\d+))(?::(\d+))\s*$/i -// Chromium based browsers: Chrome, Brave, new Opera, new Edge +// This regex matches all the frames that have a function name. const chromeRegex = /^\s*at (?:(.+?\)(?: \[.+\])?|.*?) ?\((?:address at )?)?(?:async )?((?:|[-a-z]+:|.*bundle|\/)?.*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i + const chromeEvalRegex = /\((\S*)(?::(\d+))(?::(\d+))\)/ -const chrome: StackLineParserFn = (line) => { - const parts = chromeRegex.exec(line) +// Chromium based browsers: Chrome, Brave, new Opera, new Edge +// We cannot call this variable `chrome` because it can conflict with global `chrome` variable in certain environments +// See: https://github.com/getsentry/sentry-javascript/issues/6880 +const chromeStackParserFn: StackLineParserFn = (line) => { + // If the stack line has no function name, we need to parse it differently + const noFnParts = chromeRegexNoFnName.exec(line) as null | [string, string, string, string] + + if (noFnParts) { + const [, filename, line, col] = noFnParts + return createFrame(filename, UNKNOWN_FUNCTION, +line, +col) + } + + const parts = chromeRegex.exec(line) as null | [string, string, string, string, string] if (parts) { const isEval = parts[2] && parts[2].indexOf('eval') === 0 // start of line if (isEval) { - const subMatch = chromeEvalRegex.exec(parts[2]) + const subMatch = chromeEvalRegex.exec(parts[2]) as null | [string, string, string, string] if (subMatch) { // throw out eval line/column and use top-most line/column number @@ -111,7 +129,7 @@ const chrome: StackLineParserFn = (line) => { return } -export const chromeStackLineParser: StackLineParser = [CHROME_PRIORITY, chrome] +export const chromeStackLineParser: StackLineParser = [CHROME_PRIORITY, chromeStackParserFn] // gecko regex: `(?:bundle|\d+\.js)`: `bundle` is for react native, `\d+\.js` also but specifically for ram bundles because it // generates filenames without a prefix like `file://` the filenames in the stacktrace are just 42.js @@ -121,12 +139,12 @@ const geckoREgex = const geckoEvalRegex = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i const gecko: StackLineParserFn = (line) => { - const parts = geckoREgex.exec(line) + const parts = geckoREgex.exec(line) as null | [string, string, string, string, string, string] if (parts) { const isEval = parts[3] && parts[3].indexOf(' > eval') > -1 if (isEval) { - const subMatch = geckoEvalRegex.exec(parts[3]) + const subMatch = geckoEvalRegex.exec(parts[3]) as null | [string, string, string] if (subMatch) { // throw out eval line/column and use top-most line number @@ -152,7 +170,7 @@ export const geckoStackLineParser: StackLineParser = [GECKO_PRIORITY, gecko] const winjsRegex = /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:[-a-z]+):.*?):(\d+)(?::(\d+))?\)?\s*$/i const winjs: StackLineParserFn = (line) => { - const parts = winjsRegex.exec(line) + const parts = winjsRegex.exec(line) as null | [string, string, string, string, string] return parts ? createFrame(parts[2], parts[1] || UNKNOWN_FUNCTION, +parts[3], parts[4] ? +parts[4] : undefined) @@ -164,7 +182,7 @@ export const winjsStackLineParser: StackLineParser = [WINJS_PRIORITY, winjs] const opera10Regex = / line (\d+).*script (?:in )?(\S+)(?:: in function (\S+))?$/i const opera10: StackLineParserFn = (line) => { - const parts = opera10Regex.exec(line) + const parts = opera10Regex.exec(line) as null | [string, string, string, string] return parts ? createFrame(parts[2], parts[3] || UNKNOWN_FUNCTION, +parts[1]) : undefined } @@ -173,39 +191,53 @@ export const opera10StackLineParser: StackLineParser = [OPERA10_PRIORITY, opera1 const opera11Regex = / line (\d+), column (\d+)\s*(?:in (?:]+)>|([^)]+))\(.*\))? in (.*):\s*$/i const opera11: StackLineParserFn = (line) => { - const parts = opera11Regex.exec(line) + const parts = opera11Regex.exec(line) as null | [string, string, string, string, string, string] return parts ? createFrame(parts[5], parts[3] || parts[4] || UNKNOWN_FUNCTION, +parts[1], +parts[2]) : undefined } export const opera11StackLineParser: StackLineParser = [OPERA11_PRIORITY, opera11] -export const defaultStackLineParsers = [chromeStackLineParser, geckoStackLineParser, winjsStackLineParser] +export const defaultStackLineParsers = [chromeStackLineParser, geckoStackLineParser] -export function reverse(stack: ReadonlyArray): StackFrame[] { +export const defaultStackParser = createStackParser(...defaultStackLineParsers) + +export function reverseAndStripFrames(stack: ReadonlyArray): StackFrame[] { if (!stack.length) { return [] } - const localStack = stack.slice(0, STACKTRACE_FRAME_LIMIT) + const localStack = Array.from(stack) localStack.reverse() - return localStack.map((frame) => ({ + if (STRIP_FRAME_REGEXP.test(getLastStackFrame(localStack).function || '')) { + localStack.pop() + + if (STRIP_FRAME_REGEXP.test(getLastStackFrame(localStack).function || '')) { + localStack.pop() + } + } + + return localStack.slice(0, STACKTRACE_FRAME_LIMIT).map((frame) => ({ ...frame, - filename: frame.filename || localStack[localStack.length - 1].filename, - function: frame.function || '?', + filename: frame.filename || getLastStackFrame(localStack).filename, + function: frame.function || UNKNOWN_FUNCTION, })) } +function getLastStackFrame(arr: StackFrame[]): StackFrame { + return arr[arr.length - 1] || {} +} + export function createStackParser(...parsers: StackLineParser[]): StackParser { const sortedParsers = parsers.sort((a, b) => a[0] - b[0]).map((p) => p[1]) - return (stack: string, skipFirst = 0): StackFrame[] => { + return (stack: string, skipFirstLines: number = 0): StackFrame[] => { const frames: StackFrame[] = [] const lines = stack.split('\n') - for (let i = skipFirst; i < lines.length; i++) { - const line = lines[i] + for (let i = skipFirstLines; i < lines.length; i++) { + const line = lines[i] as string // Ignore lines over 1kb as they are unlikely to be stack frames. // Many of the regular expressions use backtracking which results in run time that increases exponentially with // input size. Huge strings can result in hangs/Denial of Service: @@ -238,12 +270,10 @@ export function createStackParser(...parsers: StackLineParser[]): StackParser { } } - return reverse(frames) + return reverseAndStripFrames(frames) } } -export const defaultStackParser = createStackParser(...defaultStackLineParsers) - /** * Safari web extensions, starting version unknown, can produce "frames-only" stacktraces. * What it means, is that instead of format like: @@ -270,7 +300,7 @@ const extractSafariExtensionDetails = (func: string, filename: string): [string, return isSafariExtension || isSafariWebExtension ? [ - func.indexOf('@') !== -1 ? func.split('@')[0] : UNKNOWN_FUNCTION, + func.indexOf('@') !== -1 ? (func.split('@')[0] as string) : UNKNOWN_FUNCTION, isSafariExtension ? `safari-extension:${filename}` : `safari-web-extension:${filename}`, ] : [func, filename] From 5e2014756189415e04fb796e9e6c6e1d18f26c1a Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Tue, 15 Oct 2024 14:44:59 +0200 Subject: [PATCH 07/14] feat: report reason for recording start (#1452) Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> --- cypress/e2e/session-recording.cy.ts | 122 ++++++++++++------ .../replay/sessionrecording.test.ts | 15 ++- src/extensions/replay/sessionrecording.ts | 73 ++++++++--- src/posthog-core.ts | 22 ++-- 4 files changed, 162 insertions(+), 70 deletions(-) diff --git a/cypress/e2e/session-recording.cy.ts b/cypress/e2e/session-recording.cy.ts index 6ffa67f05..f247b383d 100644 --- a/cypress/e2e/session-recording.cy.ts +++ b/cypress/e2e/session-recording.cy.ts @@ -60,23 +60,25 @@ function ensureActivitySendsSnapshots(initial = true) { .wait('@session-recording') .then(() => { cy.phCaptures({ full: true }).then((captures) => { - expect(captures.map((c) => c.event)).to.deep.equal(['$snapshot']) - expect(captures[0]['properties']['$snapshot_data']).to.have.length.above(14).and.below(40) + const capturedSnapshot = captures.find((e) => e.event === '$snapshot') + expect(capturedSnapshot).not.to.be.undefined + + expect(capturedSnapshot['properties']['$snapshot_data']).to.have.length.above(14).and.below(40) // a meta and then a full snapshot - expect(captures[0]['properties']['$snapshot_data'][0].type).to.equal(4) // meta - expect(captures[0]['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot + expect(capturedSnapshot['properties']['$snapshot_data'][0].type).to.equal(4) // meta + expect(capturedSnapshot['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot if (initial) { - expectSessionOptionsCustomEvent(captures[0]['properties']['$snapshot_data'][2]) - expectPostHogConfigCustomEvent(captures[0]['properties']['$snapshot_data'][3]) + expectSessionOptionsCustomEvent(capturedSnapshot['properties']['$snapshot_data'][2]) + expectPostHogConfigCustomEvent(capturedSnapshot['properties']['$snapshot_data'][3]) } else { - expectSessionOptionsCustomEvent(captures[0]['properties']['$snapshot_data'][2]) - expectPostHogConfigCustomEvent(captures[0]['properties']['$snapshot_data'][3]) - expectSessionIdChangedCustomEvent(captures[0]['properties']['$snapshot_data'][4]) + expectSessionOptionsCustomEvent(capturedSnapshot['properties']['$snapshot_data'][2]) + expectPostHogConfigCustomEvent(capturedSnapshot['properties']['$snapshot_data'][3]) + expectSessionIdChangedCustomEvent(capturedSnapshot['properties']['$snapshot_data'][4]) } // Making a set from the rest should all be 3 - incremental snapshots - const remainder = captures[0]['properties']['$snapshot_data'].slice(initial ? 4 : 5) + const remainder = capturedSnapshot['properties']['$snapshot_data'].slice(initial ? 4 : 5) expect(Array.from(new Set(remainder.map((s) => s.type)))).to.deep.equal([3]) }) }) @@ -112,6 +114,9 @@ describe('Session recording', () => { describe('array.full.js', () => { it('captures session events', () => { start({ + options: { + session_recording: {}, + }, decideResponseOverrides: { isAuthenticated: false, sessionRecording: { @@ -128,9 +133,13 @@ describe('Session recording', () => { .type('hello posthog!') .wait('@session-recording') .then(() => { + cy.posthog().invoke('capture', 'test_registered_property') cy.phCaptures({ full: true }).then((captures) => { - // should be a pageview and a $snapshot - expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$snapshot']) + expect(captures.map((c) => c.event)).to.deep.equal([ + '$pageview', + '$snapshot', + 'test_registered_property', + ]) expect(captures[1]['properties']['$snapshot_data']).to.have.length.above(33).and.below(40) // a meta and then a full snapshot @@ -141,6 +150,10 @@ describe('Session recording', () => { // Making a set from the rest should all be 3 - incremental snapshots const incrementalSnapshots = captures[1]['properties']['$snapshot_data'].slice(4) expect(Array.from(new Set(incrementalSnapshots.map((s) => s.type)))).to.deep.eq([3]) + + expect(captures[2]['properties']['$session_recording_start_reason']).to.equal( + 'recording_initialized' + ) }) }) }) @@ -180,6 +193,8 @@ describe('Session recording', () => { loaded: (ph) => { ph.sessionRecording._forceAllowLocalhostNetworkCapture = true }, + + session_recording: {}, }, }) @@ -271,6 +286,9 @@ describe('Session recording', () => { describe('array.js', () => { beforeEach(() => { start({ + options: { + session_recording: {}, + }, decideResponseOverrides: { isAuthenticated: false, sessionRecording: { @@ -386,10 +404,11 @@ describe('Session recording', () => { it('continues capturing to the same session when the page reloads', () => { let sessionId: string | null = null - // cypress time handling can confuse when to run full snapshot, let's force that to happen... cy.get('[data-cy-input]').type('hello world! ') cy.wait('@session-recording').then(() => { cy.phCaptures({ full: true }).then((captures) => { + expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$snapshot']) + captures.forEach((c) => { if (isNull(sessionId)) { sessionId = c.properties['$session_id'] @@ -404,7 +423,9 @@ describe('Session recording', () => { cy.resetPhCaptures() // and refresh the page cy.reload() - cy.posthogInit({}) + cy.posthogInit({ + session_recording: {}, + }) cy.wait('@decide') cy.wait('@recorder-script') @@ -418,10 +439,13 @@ describe('Session recording', () => { cy.phCaptures({ full: true }).then((captures) => { // should be a $snapshot for the current session expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$snapshot']) + expect(captures[0].properties['$session_id']).to.equal(sessionId) - expect(captures[1].properties['$session_id']).to.equal(sessionId) - expect(captures[1]['properties']['$snapshot_data']).to.have.length.above(0) + const capturedSnapshot = captures[1] + expect(capturedSnapshot.properties['$session_id']).to.equal(sessionId) + + expect(capturedSnapshot['properties']['$snapshot_data']).to.have.length.above(0) /** * the snapshots will look a little like: @@ -433,28 +457,28 @@ describe('Session recording', () => { // page reloaded so we will start with a full snapshot // a meta and then a full snapshot - expect(captures[1]['properties']['$snapshot_data'][0].type).to.equal(4) // meta - expect(captures[1]['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot + expect(capturedSnapshot['properties']['$snapshot_data'][0].type).to.equal(4) // meta + expect(capturedSnapshot['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot // these custom events should always be in the same order, but computers // we don't care if they are present and in a changing order const customEvents = sortByTag([ - captures[1]['properties']['$snapshot_data'][2], - captures[1]['properties']['$snapshot_data'][3], - captures[1]['properties']['$snapshot_data'][4], + capturedSnapshot['properties']['$snapshot_data'][2], + capturedSnapshot['properties']['$snapshot_data'][3], + capturedSnapshot['properties']['$snapshot_data'][4], ]) expectPageViewCustomEvent(customEvents[0]) expectPostHogConfigCustomEvent(customEvents[1]) expectSessionOptionsCustomEvent(customEvents[2]) const xPositions = [] - for (let i = 5; i < captures[1]['properties']['$snapshot_data'].length; i++) { - expect(captures[1]['properties']['$snapshot_data'][i].type).to.equal(3) - expect(captures[1]['properties']['$snapshot_data'][i].data.source).to.equal( + for (let i = 5; i < capturedSnapshot['properties']['$snapshot_data'].length; i++) { + expect(capturedSnapshot['properties']['$snapshot_data'][i].type).to.equal(3) + expect(capturedSnapshot['properties']['$snapshot_data'][i].data.source).to.equal( 6, - JSON.stringify(captures[1]['properties']['$snapshot_data'][i]) + JSON.stringify(capturedSnapshot['properties']['$snapshot_data'][i]) ) - xPositions.push(captures[1]['properties']['$snapshot_data'][i].data.positions[0].x) + xPositions.push(capturedSnapshot['properties']['$snapshot_data'][i].data.positions[0].x) } // even though we trigger 4 events, only 2 snapshots should be captured @@ -478,11 +502,20 @@ describe('Session recording', () => { .type('hello posthog!') .wait('@session-recording') .then(() => { + cy.posthog().invoke('capture', 'test_registered_property') cy.phCaptures({ full: true }).then((captures) => { - // should be a pageview and a $snapshot - expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$snapshot']) + expect(captures.map((c) => c.event)).to.deep.equal([ + '$pageview', + '$snapshot', + 'test_registered_property', + ]) + expect(captures[1]['properties']['$session_id']).to.be.a('string') firstSessionId = captures[1]['properties']['$session_id'] + + expect(captures[2]['properties']['$session_recording_start_reason']).to.equal( + 'recording_initialized' + ) }) }) @@ -505,24 +538,29 @@ describe('Session recording', () => { .type('hello posthog!') .wait('@session-recording', { timeout: 10000 }) .then(() => { + cy.posthog().invoke('capture', 'test_registered_property') cy.phCaptures({ full: true }).then((captures) => { - // should be a pageview and a $snapshot - expect(captures[0].event).to.equal('$snapshot') + const capturedSnapshot = captures[0] + expect(capturedSnapshot.event).to.equal('$snapshot') - expect(captures[0]['properties']['$session_id']).to.be.a('string') - expect(captures[0]['properties']['$session_id']).not.to.eq(firstSessionId) + expect(capturedSnapshot['properties']['$session_id']).to.be.a('string') + expect(capturedSnapshot['properties']['$session_id']).not.to.eq(firstSessionId) - expect(captures[0]['properties']['$snapshot_data']).to.have.length.above(0) - expect(captures[0]['properties']['$snapshot_data'][0].type).to.equal(4) // meta - expect(captures[0]['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot + expect(capturedSnapshot['properties']['$snapshot_data']).to.have.length.above(0) + expect(capturedSnapshot['properties']['$snapshot_data'][0].type).to.equal(4) // meta + expect(capturedSnapshot['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot + + expect(captures[1].event).to.equal('test_registered_property') + expect(captures[1]['properties']['$session_recording_start_reason']).to.equal( + 'session_id_changed' + ) }) }) }) it('starts a new recording after calling reset', () => { cy.phCaptures({ full: true }).then((captures) => { - // should be a pageview at the beginning - expect(captures.map((c) => c.event)).to.deep.equal(['$pageview']) + expect(captures[0].event).to.eq('$pageview') }) cy.resetPhCaptures() @@ -553,6 +591,9 @@ describe('Session recording', () => { describe('with sampling', () => { beforeEach(() => { start({ + options: { + session_recording: {}, + }, decideResponseOverrides: { isAuthenticated: false, sessionRecording: { @@ -575,7 +616,6 @@ describe('Session recording', () => { .wait(200) // can't wait on call to session recording, it's not going to happen .then(() => { cy.phCaptures({ full: true }).then((captures) => { - // should be a pageview and a $snapshot expect(captures.map((c) => c.event)).to.deep.equal(['$pageview']) }) }) @@ -611,6 +651,12 @@ describe('Session recording', () => { // no call to session-recording yet }) + cy.posthog().invoke('capture', 'test_registered_property') + cy.phCaptures({ full: true }).then((captures) => { + expect((captures || []).map((c) => c.event)).to.deep.equal(['$pageview', 'test_registered_property']) + expect(captures[1]['properties']['$session_recording_start_reason']).to.equal('sampling_override') + }) + cy.resetPhCaptures() cy.get('[data-cy-input]').type('hello posthog!') diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index fc110b397..bbb709194 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -44,6 +44,7 @@ import { pluginEvent, } from '@rrweb/types' import Mock = jest.Mock +import { ConsentManager } from '../../../consent' // Type and source defined here designate a non-user-generated recording event @@ -243,14 +244,22 @@ describe('SessionRecording', () => { config: config, capture: jest.fn(), persistence: postHogPersistence, - onFeatureFlags: (cb: (flags: string[]) => void) => { + onFeatureFlags: ( + cb: (flags: string[], variants: Record) => void + ): (() => void) => { onFeatureFlagsCallback = cb + return () => {} }, sessionManager: sessionManager, requestRouter: new RequestRouter({ config } as any), _addCaptureHook: addCaptureHookMock, - consent: { isOptedOut: () => false }, - } as unknown as PostHog + consent: { + isOptedOut(): boolean { + return false + }, + } as unknown as ConsentManager, + register_for_session() {}, + } as Partial as PostHog loadScriptMock.mockImplementation((_ph, _path, callback) => { addRRwebToWindow() diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 8c196c883..e7b62d34e 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -34,6 +34,14 @@ import { isLocalhost } from '../../utils/request-utils' import { MutationRateLimiter } from './mutation-rate-limiter' import { gzipSync, strFromU8, strToU8 } from 'fflate' +type SessionStartReason = + | 'sampling_override' + | 'recording_initialized' + | 'linked_flag_match' + | 'linked_flag_override' + | 'sampling' + | 'session_id_changed' + const BASE_ENDPOINT = '/s/' const FIVE_MINUTES = 1000 * 60 * 5 @@ -392,9 +400,9 @@ export class SessionRecording { } } - startIfEnabledOrStop() { + startIfEnabledOrStop(startReason?: SessionStartReason) { if (this.isRecordingEnabled) { - this._startCapture() + this._startCapture(startReason) // calling addEventListener multiple times is safe and will not add duplicates window?.addEventListener('beforeunload', this._onBeforeUnload) @@ -496,15 +504,21 @@ export class SessionRecording { shouldSample = storedIsSampled } - if (!shouldSample && makeDecision) { - logger.warn( - LOGGER_PREFIX + - ` Sample rate (${currentSampleRate}) has determined that this sessionId (${sessionId}) will not be sent to the server.` - ) + if (makeDecision) { + if (shouldSample) { + this._reportStarted('sampling') + } else { + logger.warn( + LOGGER_PREFIX + + ` Sample rate (${currentSampleRate}) has determined that this sessionId (${sessionId}) will not be sent to the server.` + ) + } + + this._tryAddCustomEvent('samplingDecisionMade', { + sampleRate: currentSampleRate, + isSampled: shouldSample, + }) } - this._tryAddCustomEvent('samplingDecisionMade', { - sampleRate: currentSampleRate, - }) this.instance.persistence?.register({ [SESSION_RECORDING_IS_SAMPLED]: shouldSample, @@ -536,6 +550,7 @@ export class SessionRecording { const tag = 'linked flag matched' logger.info(LOGGER_PREFIX + ' ' + tag, payload) this._tryAddCustomEvent(tag, payload) + this._reportStarted('linked_flag_match') } this._linkedFlagSeen = linkedFlagMatches }) @@ -609,7 +624,7 @@ export class SessionRecording { }) } - private _startCapture() { + private _startCapture(startReason?: SessionStartReason) { if (isUndefined(Object.assign)) { // According to the rrweb docs, rrweb is not supported on IE11 and below: // "rrweb does not support IE11 and below because it uses the MutationObserver API which was supported by these browsers." @@ -648,6 +663,11 @@ export class SessionRecording { } else { this._onScriptLoaded() } + + logger.info(LOGGER_PREFIX + ' starting') + if (this.status === 'active') { + this._reportStarted(startReason || 'recording_initialized') + } } private isInteractiveEvent(event: eventWithTime) { @@ -721,7 +741,7 @@ export class SessionRecording { if (sessionIdChanged || windowIdChanged) { this.stopRecording() - this.startIfEnabledOrStop() + this.startIfEnabledOrStop('session_id_changed') } else if (returningFromIdle) { this._scheduleFullSnapshot() } @@ -838,11 +858,6 @@ export class SessionRecording { this._tryAddCustomEvent('$posthog_config', { config: this.instance.config, }) - - logger.info(LOGGER_PREFIX + ' started', { - idleThreshold: this.sessionIdleThresholdMilliseconds, - maxIdleTime: this.sessionManager.sessionTimeoutMs, - }) } private _scheduleFullSnapshot(): void { @@ -1096,5 +1111,29 @@ export class SessionRecording { * */ public overrideLinkedFlag() { this._linkedFlagSeen = true + this._reportStarted('linked_flag_override') + } + + /** + * this ignores the sampling config and causes capture to start + * (if recording would have started had the flag been received i.e. it does not override other config). + * + * It is not usual to call this directly, + * instead call `posthog.startSessionRecording({sampling: true})` + * */ + public overrideSampling() { + this.instance.persistence?.register({ + // short-circuits the `makeSamplingDecision` function in the session recording module + [SESSION_RECORDING_IS_SAMPLED]: true, + }) + this._reportStarted('sampling_override') + } + + private _reportStarted(startReason: SessionStartReason, shouldReport: () => boolean = () => true) { + if (shouldReport()) { + this.instance.register_for_session({ + $session_recording_start_reason: startReason, + }) + } } } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index e5fd4bf1c..6f9845ff7 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -17,7 +17,6 @@ import { ALIAS_ID_KEY, FLAG_CALL_REPORTED, PEOPLE_DISTINCT_ID_KEY, - SESSION_RECORDING_IS_SAMPLED, USER_STATE, ENABLE_PERSON_PROCESSING, } from './constants' @@ -320,7 +319,7 @@ export class PostHog { }, } - this.on('eventCaptured', (data) => logger.info('send', data)) + this.on('eventCaptured', (data) => logger.info(`send "${data?.event}"`, data)) } // Initialization methods @@ -1792,18 +1791,17 @@ export class PostHog { */ startSessionRecording(override?: { sampling?: boolean; linked_flag?: boolean } | true): void { const overrideAll = isBoolean(override) && override - if (overrideAll || override?.sampling) { + if (overrideAll || override?.sampling || override?.linked_flag) { // allow the session id check to rotate session id if necessary const ids = this.sessionManager?.checkAndGetSessionAndWindowId() - this.persistence?.register({ - // short-circuits the `makeSamplingDecision` function in the session recording module - [SESSION_RECORDING_IS_SAMPLED]: true, - }) - logger.info('Session recording started with sampling override for session: ', ids?.sessionId) - } - if (overrideAll || override?.linked_flag) { - this.sessionRecording?.overrideLinkedFlag() - logger.info('Session recording started with linked_flags override') + if (overrideAll || override?.sampling) { + this.sessionRecording?.overrideSampling() + logger.info('Session recording started with sampling override for session: ', ids?.sessionId) + } + if (overrideAll || override?.linked_flag) { + this.sessionRecording?.overrideLinkedFlag() + logger.info('Session recording started with linked_flags override') + } } this.set_config({ disable_session_recording: false }) } From 51050f41a6cbd497dc8d626f47eea48070bcd285 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 15 Oct 2024 12:45:33 +0000 Subject: [PATCH 08/14] chore: Bump version to 1.169.0 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5de4f23..63e848cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.169.0 - 2024-10-15 + +- feat: report reason for recording start (#1452) +- chore: improve exception autocapture (#1466) + ## 1.168.0 - 2024-10-15 - fix(errors): Better define schema, align with python (#1460) diff --git a/package.json b/package.json index 8f705c77d..8b77ff566 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.168.0", + "version": "1.169.0", "description": "Posthog-js allows you to automatically capture usage and send events to PostHog.", "repository": "https://github.com/PostHog/posthog-js", "author": "hey@posthog.com", From bd173b27bfc32c1cfa2ffc5a02a4ba26fe27ed7d Mon Sep 17 00:00:00 2001 From: David Newell Date: Wed, 16 Oct 2024 10:25:41 +0100 Subject: [PATCH 09/14] chore: skip if Array.from is missing (#1475) --- src/extensions/replay/sessionrecording.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index e7b62d34e..0f0f65bf1 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -625,13 +625,13 @@ export class SessionRecording { } private _startCapture(startReason?: SessionStartReason) { - if (isUndefined(Object.assign)) { + if (isUndefined(Object.assign) || isUndefined(Array.from)) { // According to the rrweb docs, rrweb is not supported on IE11 and below: // "rrweb does not support IE11 and below because it uses the MutationObserver API which was supported by these browsers." // https://github.com/rrweb-io/rrweb/blob/master/guide.md#compatibility-note // // However, MutationObserver does exist on IE11, it just doesn't work well and does not detect all changes. - // Instead, when we load "recorder.js", the first JS error is about "Object.assign" being undefined. + // Instead, when we load "recorder.js", the first JS error is about "Object.assign" and "Array.from" being undefined. // Thus instead of MutationObserver, we look for this function and block recording if it's undefined. return } From 4ece26b11bf3b87720292b50bfadba828e0a5ea9 Mon Sep 17 00:00:00 2001 From: daibhin Date: Wed, 16 Oct 2024 09:26:12 +0000 Subject: [PATCH 10/14] chore: Bump version to 1.169.1 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e848cad..b6a0a752e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.169.1 - 2024-10-16 + +- chore: skip if Array.from is missing (#1475) + ## 1.169.0 - 2024-10-15 - feat: report reason for recording start (#1452) diff --git a/package.json b/package.json index 8b77ff566..e4c99118e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.169.0", + "version": "1.169.1", "description": "Posthog-js allows you to automatically capture usage and send events to PostHog.", "repository": "https://github.com/PostHog/posthog-js", "author": "hey@posthog.com", From 7304175a71c3c8014911e660f58aab605c3a6949 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Wed, 16 Oct 2024 12:13:22 +0200 Subject: [PATCH 11/14] fix: web vitals delayed capture (#1474) --- src/__tests__/extensions/web-vitals.test.ts | 32 +++++++++++++++++---- src/extensions/web-vitals/index.ts | 11 +++++-- src/types.ts | 6 ++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/__tests__/extensions/web-vitals.test.ts b/src/__tests__/extensions/web-vitals.test.ts index 80c8738d6..e83ae0494 100644 --- a/src/__tests__/extensions/web-vitals.test.ts +++ b/src/__tests__/extensions/web-vitals.test.ts @@ -1,9 +1,9 @@ import { createPosthogInstance } from '../helpers/posthog-instance' import { uuidv7 } from '../../uuidv7' import { PostHog } from '../../posthog-core' -import { DecideResponse, SupportedWebVitalsMetrics } from '../../types' +import { DecideResponse, PerformanceCaptureConfig, SupportedWebVitalsMetrics } from '../../types' import { assignableWindow } from '../../utils/globals' -import { FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS, FIFTEEN_MINUTES_IN_MILLIS } from '../../extensions/web-vitals' +import { DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS, FIFTEEN_MINUTES_IN_MILLIS } from '../../extensions/web-vitals' jest.mock('../../utils/logger') jest.useFakeTimers() @@ -134,12 +134,12 @@ describe('web vitals', () => { ]) }) - it('should emit after 8 seconds even when only 1 to 3 metrics captured', async () => { + 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) - jest.advanceTimersByTime(FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1) + 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) @@ -155,12 +155,32 @@ describe('web vitals', () => { ]) }) + it('should emit after configured timeout even when only 1 to 3 metrics captured', async () => { + ;(posthog.config.capture_performance as PerformanceCaptureConfig).web_vitals_delayed_flush_ms = 1000 + onCLSCallback?.({ name: 'CLS', value: 123.45, extra: 'property' }) + + expect(onCapture).toBeCalledTimes(0) + + jest.advanceTimersByTime(1000 + 1) + + expect(onCapture.mock.lastCall).toMatchObject([ + '$web_vitals', + { + event: '$web_vitals', + properties: { + $web_vitals_CLS_event: expectedEmittedWebVitals('CLS'), + $web_vitals_CLS_value: 123.45, + }, + }, + ]) + }) + it('should ignore a ridiculous value', async () => { onCLSCallback?.({ name: 'CLS', value: FIFTEEN_MINUTES_IN_MILLIS, extra: 'property' }) expect(onCapture).toBeCalledTimes(0) - jest.advanceTimersByTime(FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1) + jest.advanceTimersByTime(DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1) expect(onCapture.mock.calls).toEqual([]) }) @@ -171,7 +191,7 @@ describe('web vitals', () => { expect(onCapture).toBeCalledTimes(0) - jest.advanceTimersByTime(FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1) + jest.advanceTimersByTime(DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1) expect(onCapture).toBeCalledTimes(1) }) diff --git a/src/extensions/web-vitals/index.ts b/src/extensions/web-vitals/index.ts index 1d966c08d..706806caf 100644 --- a/src/extensions/web-vitals/index.ts +++ b/src/extensions/web-vitals/index.ts @@ -7,7 +7,7 @@ import { assignableWindow, window } from '../../utils/globals' type WebVitalsMetricCallback = (metric: any) => void -export const FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS = 8000 +export const DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS = 5000 const ONE_MINUTE_IN_MILLIS = 60 * 1000 export const FIFTEEN_MINUTES_IN_MILLIS = 15 * ONE_MINUTE_IN_MILLIS @@ -38,6 +38,13 @@ export class WebVitalsAutocapture { : this.instance.persistence?.props[WEB_VITALS_ALLOWED_METRICS] || ['CLS', 'FCP', 'INP', 'LCP'] } + public get flushToCaptureTimeoutMs(): number { + const clientConfig: number | undefined = isObject(this.instance.config.capture_performance) + ? this.instance.config.capture_performance.web_vitals_delayed_flush_ms + : undefined + return clientConfig || DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + } + public get _maxAllowedValue(): number { const configured = isObject(this.instance.config.capture_performance) && @@ -163,7 +170,7 @@ export class WebVitalsAutocapture { // poor performance is >4s, we wait twice that time to send // this is in case we haven't received all metrics // we'll at least gather some - this._delayedFlushTimer = setTimeout(this._flushToCapture, FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS) + this._delayedFlushTimer = setTimeout(this._flushToCapture, this.flushToCaptureTimeoutMs) } if (isUndefined(this.buffer.url)) { diff --git a/src/types.ts b/src/types.ts index e18ced49f..21f49efa3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -114,6 +114,12 @@ export interface PerformanceCaptureConfig { * NB setting this does not override whether the capture is enabled */ web_vitals_allowed_metrics?: SupportedWebVitalsMetrics[] + /** + * we delay flushing web vitals metrics to reduce the number of events we send + * this is the maximum time we will wait before sending the metrics + * if not set it defaults to 5 seconds + */ + web_vitals_delayed_flush_ms?: number } export interface HeatmapConfig { From 0423e9632497e22750b9a1d02bda8abb90ab64c1 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 16 Oct 2024 10:14:06 +0000 Subject: [PATCH 12/14] chore: Bump version to 1.170.0 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a0a752e..bd7e689de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.170.0 - 2024-10-16 + +- fix: web vitals delayed capture (#1474) + ## 1.169.1 - 2024-10-16 - chore: skip if Array.from is missing (#1475) diff --git a/package.json b/package.json index e4c99118e..062652f20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.169.1", + "version": "1.170.0", "description": "Posthog-js allows you to automatically capture usage and send events to PostHog.", "repository": "https://github.com/PostHog/posthog-js", "author": "hey@posthog.com", From ffad724b9337e966573f9636854562933d793180 Mon Sep 17 00:00:00 2001 From: David Newell Date: Wed, 16 Oct 2024 12:42:15 +0100 Subject: [PATCH 13/14] feat: add stack to stacktraceless "exceptions" (#1472) --- cypress/e2e/error-tracking.cy.ts | 16 +++++ playground/cypress-full/index.html | 4 ++ playground/cypress/index.html | 4 ++ .../exception-autocapture/error-conversion.ts | 66 ++++++++++++------- .../exception-autocapture/stack-trace.ts | 9 --- .../exception-autocapture/type-checking.ts | 1 + src/posthog-core.ts | 15 +++-- src/types.ts | 1 + 8 files changed, 77 insertions(+), 39 deletions(-) diff --git a/cypress/e2e/error-tracking.cy.ts b/cypress/e2e/error-tracking.cy.ts index 94c685b11..4a84ed9ee 100644 --- a/cypress/e2e/error-tracking.cy.ts +++ b/cypress/e2e/error-tracking.cy.ts @@ -41,6 +41,22 @@ describe('Exception capture', () => { cy.wait('@exception-autocapture-script') }) + it('adds stacktrace to captured strings', () => { + cy.get('[data-cy-exception-string-button]').click() + + // ugh + cy.wait(1500) + + cy.phCaptures({ full: true }).then((captures) => { + expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$autocapture', '$exception']) + expect(captures[2].event).to.be.eql('$exception') + expect(captures[2].properties.$exception_list[0].stacktrace.frames.length).to.be.eq(1) + expect(captures[2].properties.$exception_list[0].stacktrace.frames[0].function).to.be.eq( + 'HTMLButtonElement.onclick' + ) + }) + }) + it('autocaptures exceptions', () => { cy.get('[data-cy-button-throws-error]').click() diff --git a/playground/cypress-full/index.html b/playground/cypress-full/index.html index a6807cee8..a55faa495 100644 --- a/playground/cypress-full/index.html +++ b/playground/cypress-full/index.html @@ -40,6 +40,10 @@ Send an exception + +
diff --git a/src/extensions/exception-autocapture/error-conversion.ts b/src/extensions/exception-autocapture/error-conversion.ts index f4997690d..4c92a6d93 100644 --- a/src/extensions/exception-autocapture/error-conversion.ts +++ b/src/extensions/exception-autocapture/error-conversion.ts @@ -56,7 +56,7 @@ export interface ErrorConversions { const ERROR_TYPES_PATTERN = /^(?:[Uu]ncaught (?:exception: )?)?(?:((?:Eval|Internal|Range|Reference|Syntax|Type|URI|)Error): )?(.*)$/i -export function parseStackFrames(ex: Error & { framesToPop?: number; stacktrace?: string }): StackFrame[] { +export function parseStackFrames(ex: Error & { stacktrace?: string }, framesToPop: number = 0): StackFrame[] { // Access and store the stacktrace property before doing ANYTHING // else to it because Opera is not very good at providing it // reliably in other circumstances. @@ -65,7 +65,9 @@ export function parseStackFrames(ex: Error & { framesToPop?: number; stacktrace? const skipLines = getSkipFirstStackStringLines(ex) try { - return defaultStackParser(stacktrace, skipLines) + const frames = defaultStackParser(stacktrace, skipLines) + // frames are reversed so we remove the from the back of the array + return frames.slice(0, frames.length - framesToPop) } catch { // no-empty } @@ -146,17 +148,26 @@ function errorPropertiesFromString(candidate: string, metadata?: ErrorMetadata): ? candidate : metadata?.defaultExceptionMessage + const exception: Exception = { + type: exceptionType, + value: exceptionMessage, + mechanism: { + handled, + synthetic, + }, + } + + if (metadata?.syntheticException) { + // Kludge: strip the last frame from a synthetically created error + // so that it does not appear in a users stack trace + const frames = parseStackFrames(metadata.syntheticException, 1) + if (frames.length) { + exception.stacktrace = { frames } + } + } + return { - $exception_list: [ - { - type: exceptionType, - value: exceptionMessage, - mechanism: { - handled, - synthetic, - }, - }, - ], + $exception_list: [exception], $exception_level: 'error', } } @@ -206,17 +217,26 @@ function errorPropertiesFromObject(candidate: Record, metadata? ? metadata.overrideExceptionMessage : `Non-Error ${'exception'} captured with keys: ${extractExceptionKeysForMessage(candidate)}` + const exception: Exception = { + type: exceptionType, + value: exceptionMessage, + mechanism: { + handled, + synthetic, + }, + } + + if (metadata?.syntheticException) { + // Kludge: strip the last frame from a synthetically created error + // so that it does not appear in a users stack trace + const frames = parseStackFrames(metadata?.syntheticException, 1) + if (frames.length) { + exception.stacktrace = { frames } + } + } + return { - $exception_list: [ - { - type: exceptionType, - value: exceptionMessage, - mechanism: { - handled, - synthetic, - }, - }, - ], + $exception_list: [exception], $exception_level: isSeverityLevel(candidate.level) ? candidate.level : 'error', } } @@ -259,7 +279,7 @@ export function errorToProperties( } else if (isPlainObject(candidate) || isEvent(candidate)) { // group these by using the keys available on the object const objectException = candidate as Record - return errorPropertiesFromObject(objectException) + return errorPropertiesFromObject(objectException, metadata) } else if (isUndefined(error) && isString(event)) { let name = 'Error' let message = event diff --git a/src/extensions/exception-autocapture/stack-trace.ts b/src/extensions/exception-autocapture/stack-trace.ts index f09127713..a3e6d041b 100644 --- a/src/extensions/exception-autocapture/stack-trace.ts +++ b/src/extensions/exception-autocapture/stack-trace.ts @@ -51,7 +51,6 @@ export interface StackFrame { } const WEBPACK_ERROR_REGEXP = /\(error: (.*)\)/ -const STRIP_FRAME_REGEXP = /captureException/ const STACKTRACE_FRAME_LIMIT = 50 const UNKNOWN_FUNCTION = '?' @@ -210,14 +209,6 @@ export function reverseAndStripFrames(stack: ReadonlyArray): StackFr localStack.reverse() - if (STRIP_FRAME_REGEXP.test(getLastStackFrame(localStack).function || '')) { - localStack.pop() - - if (STRIP_FRAME_REGEXP.test(getLastStackFrame(localStack).function || '')) { - localStack.pop() - } - } - return localStack.slice(0, STACKTRACE_FRAME_LIMIT).map((frame) => ({ ...frame, filename: frame.filename || getLastStackFrame(localStack).filename, diff --git a/src/extensions/exception-autocapture/type-checking.ts b/src/extensions/exception-autocapture/type-checking.ts index 01016ee6f..4e561e806 100644 --- a/src/extensions/exception-autocapture/type-checking.ts +++ b/src/extensions/exception-autocapture/type-checking.ts @@ -27,6 +27,7 @@ export function isError(candidate: unknown): candidate is Error { case '[object Error]': case '[object Exception]': case '[object DOMException]': + case '[object DOMError]': return true default: return isInstanceOf(candidate, Error) diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 6f9845ff7..c5f4a9bec 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -1824,14 +1824,15 @@ export class PostHog { /** Capture a caught exception manually */ captureException(error: Error, additionalProperties?: Properties): void { + const syntheticException = new Error('PostHog syntheticException') const properties: Properties = isFunction(assignableWindow.__PosthogExtensions__?.parseErrorAsProperties) - ? assignableWindow.__PosthogExtensions__.parseErrorAsProperties([ - error.message, - undefined, - undefined, - undefined, - error, - ]) + ? assignableWindow.__PosthogExtensions__.parseErrorAsProperties( + [error.message, undefined, undefined, undefined, error], + // create synthetic error to get stack in cases where user input does not contain one + // creating the exceptionas soon into our code as possible means we should only have to + // remove a single frame (this 'captureException' method) from the resultant stack + { syntheticException } + ) : { $exception_level: 'error', $exception_list: [ diff --git a/src/types.ts b/src/types.ts index 21f49efa3..bd28662cf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -579,6 +579,7 @@ export type ErrorEventArgs = [ export type ErrorMetadata = { handled?: boolean synthetic?: boolean + syntheticException?: Error overrideExceptionType?: string overrideExceptionMessage?: string defaultExceptionType?: string From 6d475cce35a4f088387c417ad3201249405c7930 Mon Sep 17 00:00:00 2001 From: daibhin Date: Wed, 16 Oct 2024 11:42:56 +0000 Subject: [PATCH 14/14] chore: Bump version to 1.170.1 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd7e689de..cbc9478de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.170.1 - 2024-10-16 + +- feat: add stack to stacktraceless "exceptions" (#1472) + ## 1.170.0 - 2024-10-16 - fix: web vitals delayed capture (#1474) diff --git a/package.json b/package.json index 062652f20..680b1f437 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.170.0", + "version": "1.170.1", "description": "Posthog-js allows you to automatically capture usage and send events to PostHog.", "repository": "https://github.com/PostHog/posthog-js", "author": "hey@posthog.com",