Skip to content

Commit 11dd02f

Browse files
authored
feat: Exception Autocapture (#649)
adds autocapture for exceptions autocapture for unhandled promise rejection manual exception capture
1 parent 0e20829 commit 11dd02f

File tree

14 files changed

+965
-3
lines changed

14 files changed

+965
-3
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ cypress/videos
2525
coverage
2626
react/dist
2727
test-result.json
28-
yarn-error.log
28+
yarn-error.log
29+
/.eslintcache

cypress/e2e/capture.cy.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ describe('Event capture', () => {
5555
cy.phCaptures().should('include', 'custom-event')
5656
})
5757

58+
it('captures exceptions', () => {
59+
given('options', () => ({
60+
autocapture_exceptions: true,
61+
}))
62+
63+
start()
64+
65+
cy.get('[data-cy-exception-button]').click()
66+
cy.phCaptures().should('have.length', 3)
67+
cy.phCaptures().should('include', '$exception')
68+
})
69+
5870
describe('autocapture config', () => {
5971
it('dont capture click when configured not to', () => {
6072
given('options', () => ({

playground/cypress-full/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@
3636
Sensitive attributes!
3737
</button>
3838

39+
<button data-cy-exception-button onclick="posthog.captureException(new Error('wat even am I'), { extra_prop: 2 })">
40+
Send an exception
41+
</button>
42+
43+
<br />
44+
3945
<script>
4046
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.full.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
4147
</script>
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import {
2+
errorToProperties,
3+
ErrorProperties,
4+
unhandledRejectionToProperties,
5+
} from '../../../extensions/exceptions/error-conversion'
6+
7+
// ugh, jest
8+
// can't reference PromiseRejectionEvent to construct it 🤷
9+
export type PromiseRejectionEventTypes = 'rejectionhandled' | 'unhandledrejection'
10+
11+
export type PromiseRejectionEventInit = {
12+
promise: Promise<any>
13+
reason: any
14+
}
15+
16+
export class PromiseRejectionEvent extends Event {
17+
public readonly promise: Promise<any>
18+
public readonly reason: any
19+
20+
public constructor(type: PromiseRejectionEventTypes, options: PromiseRejectionEventInit) {
21+
super(type)
22+
23+
this.promise = options.promise
24+
this.reason = options.reason
25+
}
26+
}
27+
// ugh, jest
28+
29+
describe('Error conversion', () => {
30+
it('should convert a string to an error', () => {
31+
const expected: ErrorProperties = {
32+
$exception_type: 'InternalError',
33+
$exception_message: 'but somehow still a string',
34+
$exception_is_synthetic: true,
35+
}
36+
expect(errorToProperties(['Uncaught exception: InternalError: but somehow still a string'])).toEqual(expected)
37+
})
38+
39+
it('should convert a plain object to an error', () => {
40+
const expected: ErrorProperties = {
41+
$exception_type: 'Error',
42+
$exception_message: 'Non-Error exception captured with keys: foo, string',
43+
$exception_is_synthetic: true,
44+
}
45+
expect(errorToProperties([{ string: 'candidate', foo: 'bar' } as unknown as Event])).toEqual(expected)
46+
})
47+
48+
it('should convert a plain Event to an error', () => {
49+
const expected: ErrorProperties = {
50+
$exception_type: 'MouseEvent',
51+
$exception_message: 'Non-Error exception captured with keys: isTrusted',
52+
$exception_is_synthetic: true,
53+
}
54+
const event = new MouseEvent('click', { bubbles: true, cancelable: true, composed: true })
55+
expect(errorToProperties([event])).toEqual(expected)
56+
})
57+
58+
it('should convert a plain Error to an error', () => {
59+
const error = new Error('oh no an error has happened')
60+
61+
const errorProperties = errorToProperties(['something', undefined, undefined, undefined, error])
62+
if (errorProperties === null) {
63+
throw new Error("this mustn't be null")
64+
}
65+
66+
expect(Object.keys(errorProperties)).toHaveLength(3)
67+
expect(errorProperties.$exception_type).toEqual('Error')
68+
expect(errorProperties.$exception_message).toEqual('oh no an error has happened')
69+
// the stack trace changes between runs, so we just check that it's there
70+
expect(errorProperties.$exception_stack_trace_raw).toBeDefined()
71+
expect(errorProperties.$exception_stack_trace_raw).toContain('{"filename')
72+
})
73+
74+
class FakeDomError {
75+
constructor(public name: string, public message: string) {}
76+
[Symbol.toStringTag] = 'DOMError'
77+
}
78+
79+
it('should convert a DOM Error to an error', () => {
80+
const expected: ErrorProperties = {
81+
$exception_type: 'Error',
82+
$exception_message: 'click: foo',
83+
}
84+
const event = new FakeDomError('click', 'foo')
85+
expect(errorToProperties([event as unknown as Event])).toEqual(expected)
86+
})
87+
88+
it('should convert a DOM Exception to an error', () => {
89+
const event = new DOMException('oh no disaster', 'dom-exception')
90+
const errorProperties = errorToProperties([event as unknown as Event])
91+
92+
if (errorProperties === null) {
93+
throw new Error("this mustn't be null")
94+
}
95+
96+
expect(Object.keys(errorProperties)).toHaveLength(4)
97+
expect(errorProperties.$exception_type).toEqual('dom-exception')
98+
expect(errorProperties.$exception_message).toEqual('oh no disaster')
99+
// the stack trace changes between runs, so we just check that it's there
100+
expect(errorProperties.$exception_stack_trace_raw).toBeDefined()
101+
expect(errorProperties.$exception_stack_trace_raw).toContain('{"filename')
102+
})
103+
104+
it('should convert an error event to an error', () => {
105+
const event = new ErrorEvent('oh no an error event', { error: new Error('the real error is hidden inside') })
106+
107+
const errorProperties = errorToProperties([event as unknown as Event])
108+
if (errorProperties === null) {
109+
throw new Error("this mustn't be null")
110+
}
111+
112+
expect(Object.keys(errorProperties)).toHaveLength(3)
113+
expect(errorProperties.$exception_type).toEqual('Error')
114+
expect(errorProperties.$exception_message).toEqual('the real error is hidden inside')
115+
// the stack trace changes between runs, so we just check that it's there
116+
expect(errorProperties.$exception_stack_trace_raw).toBeDefined()
117+
expect(errorProperties.$exception_stack_trace_raw).toContain('{"filename')
118+
})
119+
120+
it('can convert source, lineno, colno', () => {
121+
const expected: ErrorProperties = {
122+
$exception_colno: 200,
123+
$exception_is_synthetic: true,
124+
$exception_lineno: 12,
125+
$exception_message: 'string candidate',
126+
$exception_source: 'a source',
127+
$exception_type: 'Error',
128+
}
129+
expect(errorToProperties(['string candidate', 'a source', 12, 200])).toEqual(expected)
130+
})
131+
132+
it('should convert unhandled promise rejection that the browser has messed around with', () => {
133+
const ce = new CustomEvent('unhandledrejection', {
134+
detail: {
135+
promise: {},
136+
reason: new Error('a wrapped rejection event'),
137+
},
138+
})
139+
const errorProperties: ErrorProperties = unhandledRejectionToProperties([
140+
ce as unknown as PromiseRejectionEvent,
141+
])
142+
expect(Object.keys(errorProperties)).toHaveLength(4)
143+
expect(errorProperties.$exception_type).toEqual('UnhandledRejection')
144+
expect(errorProperties.$exception_message).toEqual('a wrapped rejection event')
145+
expect(errorProperties.$exception_handled).toEqual(false)
146+
// the stack trace changes between runs, so we just check that it's there
147+
expect(errorProperties.$exception_stack_trace_raw).toBeDefined()
148+
expect(errorProperties.$exception_stack_trace_raw).toContain('{"filename')
149+
})
150+
151+
it('should convert unhandled promise rejection', () => {
152+
const pre = new PromiseRejectionEvent('unhandledrejection', {
153+
promise: Promise.resolve('wat'),
154+
reason: 'My house is on fire',
155+
})
156+
const errorProperties: ErrorProperties = unhandledRejectionToProperties([
157+
pre as unknown as PromiseRejectionEvent,
158+
])
159+
expect(Object.keys(errorProperties)).toHaveLength(3)
160+
expect(errorProperties.$exception_type).toEqual('UnhandledRejection')
161+
expect(errorProperties.$exception_message).toEqual(
162+
'Non-Error promise rejection captured with value: My house is on fire'
163+
)
164+
expect(errorProperties.$exception_handled).toEqual(false)
165+
})
166+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2+
import { PostHog } from '../../../posthog-core'
3+
import { PostHogConfig } from '../../../types'
4+
import { ExceptionObserver } from '../../../extensions/exceptions/exception-autocapture'
5+
6+
describe('Exception Observer', () => {
7+
let exceptionObserver: ExceptionObserver
8+
let mockPostHogInstance: any
9+
const mockConfig: Partial<PostHogConfig> = {
10+
api_host: 'https://app.posthog.com',
11+
}
12+
13+
beforeEach(() => {
14+
mockPostHogInstance = {
15+
get_config: jest.fn((key: string) => mockConfig[key as keyof PostHogConfig]),
16+
}
17+
exceptionObserver = new ExceptionObserver(mockPostHogInstance as PostHog)
18+
jest.clearAllMocks()
19+
})
20+
21+
describe('catch exceptions', () => {
22+
it('should instrument handlers when started', () => {
23+
exceptionObserver.startCapturing()
24+
25+
expect((window.onerror as any).__POSTHOG_INSTRUMENTED__).toBe(true)
26+
expect((window.onunhandledrejection as any).__POSTHOG_INSTRUMENTED__).toBe(true)
27+
})
28+
29+
it('should remove instrument handlers when stopped', () => {
30+
exceptionObserver.stopCapturing()
31+
32+
expect((window.onerror as any)?.__POSTHOG_INSTRUMENTED__).not.toBeDefined()
33+
expect((window.onunhandledrejection as any)?.__POSTHOG_INSTRUMENTED__).not.toBeDefined()
34+
})
35+
36+
it('can report that it has started', () => {
37+
expect(exceptionObserver.isCapturing()).toBe(false)
38+
exceptionObserver.startCapturing()
39+
expect(exceptionObserver.isCapturing()).toBe(true)
40+
})
41+
})
42+
})

src/__tests__/extensions/web-performance.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe('WebPerformance', () => {
3333

3434
beforeEach(() => {
3535
mockPostHogInstance = {
36-
get_config: jest.fn((key: string) => mockConfig[key]),
36+
get_config: jest.fn((key: string) => mockConfig[key as keyof PostHogConfig]),
3737
sessionRecording: {
3838
onRRwebEmit: jest.fn(),
3939
},

src/decide.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export class Decide {
5252
this.instance.sessionRecording?.afterDecideResponse(response)
5353
autocapture.afterDecideResponse(response, this.instance)
5454
this.instance.webPerformance?.afterDecideResponse(response)
55+
this.instance.exceptionAutocapture?.afterDecideResponse(response)
5556

5657
this.instance.featureFlags.receivedFeatureFlags(response)
5758

0 commit comments

Comments
 (0)