diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea90f02e8..cbc9478de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,29 @@
+## 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)
+
+## 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)
+- chore: improve exception autocapture (#1466)
+
+## 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)
+
## 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/cypress/e2e/error-tracking.cy.ts b/cypress/e2e/error-tracking.cy.ts
index 2b58ede97..4a84ed9ee 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')
})
})
@@ -42,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()
@@ -51,11 +66,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 +82,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/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..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,10 +193,12 @@ describe('Session recording', () => {
loaded: (ph) => {
ph.sessionRecording._forceAllowLocalhostNetworkCapture = true
},
+
+ session_recording: {},
},
})
- cy.wait('@recorder')
+ cy.wait('@recorder-script')
cy.intercept({ url: 'https://example.com', times: 1 }, (req) => {
req.reply({
@@ -271,6 +286,9 @@ describe('Session recording', () => {
describe('array.js', () => {
beforeEach(() => {
start({
+ options: {
+ session_recording: {},
+ },
decideResponseOverrides: {
isAuthenticated: false,
sessionRecording: {
@@ -281,7 +299,7 @@ describe('Session recording', () => {
},
url: './playground/cypress',
})
- cy.wait('@recorder')
+ cy.wait('@recorder-script')
})
it('captures session events', () => {
@@ -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,9 +423,11 @@ describe('Session recording', () => {
cy.resetPhCaptures()
// and refresh the page
cy.reload()
- cy.posthogInit({})
+ cy.posthogInit({
+ session_recording: {},
+ })
cy.wait('@decide')
- cy.wait('@recorder')
+ cy.wait('@recorder-script')
cy.get('body')
.trigger('mousemove', { clientX: 200, clientY: 300 })
@@ -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: {
@@ -564,7 +605,7 @@ describe('Session recording', () => {
},
url: './playground/cypress',
})
- cy.wait('@recorder')
+ cy.wait('@recorder-script')
})
it('does not capture when sampling is set to 0', () => {
@@ -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'])
})
})
@@ -594,7 +634,7 @@ describe('Session recording', () => {
}).as('decide')
assertWhetherPostHogRequestsWereCalled({
- '@recorder': true,
+ '@recorder-script': true,
'@decide': true,
'@session-recording': false,
})
@@ -606,11 +646,17 @@ describe('Session recording', () => {
cy.posthog().invoke('startSessionRecording', { sampling: true })
assertWhetherPostHogRequestsWereCalled({
- '@recorder': true,
+ '@recorder-script': true,
'@decide': true,
// 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!')
@@ -637,7 +683,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: {},
diff --git a/package.json b/package.json
index 09de802fe..680b1f437 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "posthog-js",
- "version": "1.167.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",
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/__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/__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/__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/__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/extensions/exception-autocapture/error-conversion.ts b/src/extensions/exception-autocapture/error-conversion.ts
index 26398a9b5..4c92a6d93 100644
--- a/src/extensions/exception-autocapture/error-conversion.ts
+++ b/src/extensions/exception-autocapture/error-conversion.ts
@@ -10,9 +10,45 @@ import {
} from './type-checking'
import { defaultStackParser, StackFrame } from './stack-trace'
-import { isEmptyString, isNumber, isString, isUndefined } from '../../utils/type-utils'
-import { ErrorEventArgs, ErrorProperties, SeverityLevel, severityLevels } from '../../types'
+import { isEmptyString, isString, isUndefined } from '../../utils/type-utils'
+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
*/
@@ -20,32 +56,18 @@ import { ErrorEventArgs, ErrorProperties, SeverityLevel, severityLevels } from '
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[] {
+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.
const stacktrace = ex.stacktrace || ex.stack || ''
- const popSize = getPopSize(ex)
+ const skipLines = getSkipFirstStackStringLines(ex)
try {
- return defaultStackParser(stacktrace, popSize)
+ 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
}
@@ -53,21 +75,99 @@ export function parseStackFrames(ex: Error & { framesToPop?: number; stacktrace?
return []
}
-function errorPropertiesFromError(error: Error): ErrorProperties {
+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)
+ const handled = metadata?.handled ?? true
+ const synthetic = metadata?.synthetic ?? false
+
+ const exceptionType = metadata?.overrideExceptionType ? metadata.overrideExceptionType : error.name
+ const exceptionMessage = metadata?.overrideExceptionMessage
+ ? metadata.overrideExceptionMessage
+ : extractMessage(error)
+
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 {
+/**
+ * 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
+ const synthetic = metadata?.synthetic ?? true
+
+ const exceptionType = metadata?.overrideExceptionType
+ ? metadata.overrideExceptionType
+ : metadata?.defaultExceptionType ?? 'Error'
+ const exceptionMessage = metadata?.overrideExceptionMessage
+ ? metadata.overrideExceptionMessage
+ : candidate
+ ? 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_type: 'Error',
- $exception_message: candidate,
+ $exception_list: [exception],
$exception_level: 'error',
}
}
@@ -103,35 +203,50 @@ 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)}`
+
+ 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_type: isEvent(candidate) ? candidate.constructor.name : 'Error',
- $exception_message: `Non-Error ${'exception'} captured with keys: ${extractExceptionKeysForMessage(candidate)}`,
+ $exception_list: [exception],
$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,95 +257,92 @@ 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
+ return errorPropertiesFromObject(objectException, metadata)
+ } 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 errorPropertiesFromString(message, {
+ ...metadata,
+ overrideExceptionType: name,
+ defaultExceptionMessage: message,
+ })
} 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 errorPropertiesFromString(candidate as string, metadata)
}
+}
- 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 } : {}),
+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),
+ })
}
-export function unhandledRejectionToProperties([ev]: [ev: PromiseRejectionEvent]): ErrorProperties {
+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
}
- // 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)}`,
- }
- } 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 error
}
diff --git a/src/extensions/exception-autocapture/stack-trace.ts b/src/extensions/exception-autocapture/stack-trace.ts
index d39f02a6e..a3e6d041b 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,21 @@ export interface StackFrame {
debug_id?: string
}
+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
+
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 +79,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 +128,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 +138,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 +169,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 +181,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 +190,45 @@ 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 const defaultStackParser = createStackParser(...defaultStackLineParsers)
-export function reverse(stack: ReadonlyArray): StackFrame[] {
+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) => ({
+ 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 +261,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 +291,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]
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/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts
index b2e8ce253..95420aa5f 100644
--- a/src/extensions/replay/sessionrecording.ts
+++ b/src/extensions/replay/sessionrecording.ts
@@ -43,6 +43,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 ONE_MINUTE = 1000 * 60
@@ -443,9 +451,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)
@@ -550,15 +558,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,
@@ -590,6 +604,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
})
@@ -667,14 +682,14 @@ export class SessionRecording {
})
}
- private _startCapture() {
- if (isUndefined(Object.assign)) {
+ private _startCapture(startReason?: SessionStartReason) {
+ 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
}
@@ -706,6 +721,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) {
@@ -779,7 +799,7 @@ export class SessionRecording {
if (sessionIdChanged || windowIdChanged) {
this.stopRecording()
- this.startIfEnabledOrStop()
+ this.startIfEnabledOrStop('session_id_changed')
} else if (returningFromIdle) {
this._scheduleFullSnapshot()
}
@@ -896,11 +916,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 {
@@ -1192,5 +1207,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/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/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/posthog-core.ts b/src/posthog-core.ts
index 3dee01b08..c5f4a9bec 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
@@ -988,7 +987,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
}
@@ -1788,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 })
}
@@ -1822,18 +1824,27 @@ 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_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 2ecbe9a5b..268265f18 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 {
@@ -571,6 +577,16 @@ export type ErrorEventArgs = [
error?: Error | undefined
]
+export type ErrorMetadata = {
+ handled?: boolean
+ synthetic?: boolean
+ syntheticException?: Error
+ 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
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