Skip to content

Commit

Permalink
feat: manually capture errors (#1374)
Browse files Browse the repository at this point in the history
  • Loading branch information
daibhin authored Aug 29, 2024
1 parent 51cc368 commit dc66bcf
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 64 deletions.
75 changes: 58 additions & 17 deletions cypress/e2e/error-tracking.cy.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,77 @@
import { start } from '../support/setup'

describe('Exception autocapture', () => {
beforeEach(() => {
cy.on('uncaught:exception', () => {
// otherwise the exception we throw on purpose causes the test to fail
return false
})

describe('Exception capture', () => {
it('manual exception capture', () => {
start({
decideResponseOverrides: {
autocaptureExceptions: true,
autocaptureExceptions: false,
},
url: './playground/cypress',
})
cy.wait('@exception-autocapture-script')
})

it('captures exceptions', () => {
cy.get('[data-cy-button-throws-error]').click()
cy.get('[data-cy-exception-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_message).to.be.eql('This is an error')
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.$exception_source).to.match(/http:\/\/localhost:\d+\/playground\/cypress\//)
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}/
)
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
})
})

describe('Exception autocapture enabled', () => {
beforeEach(() => {
cy.on('uncaught:exception', () => {
// otherwise the exception we throw on purpose causes the test to fail
return false
})

start({
decideResponseOverrides: {
autocaptureExceptions: true,
},
url: './playground/cypress',
})
cy.wait('@exception-autocapture-script')
})

it('autocaptures exceptions', () => {
cy.get('[data-cy-button-throws-error]').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_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_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}/
)
})
})

it('sets stacktrace on manual captures if autocapture enabled', () => {
cy.get('[data-cy-exception-button]').click()

// ugh
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
})
})
})
})
2 changes: 1 addition & 1 deletion playground/cypress-full/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
<br />

<script>
!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 getSurveys getActiveMatchingSurveys".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
!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 getSurveys getActiveMatchingSurveys captureException".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
</script>
</body>
</html>
6 changes: 5 additions & 1 deletion playground/cypress/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@
Throw error
</button>

<button data-cy-exception-button onclick="posthog.captureException(new Error('wat even am I'), { extra_prop: 2 })">
Capture an exception
</button>

<script>
!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.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 getSurveys getActiveMatchingSurveys".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
!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.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 getSurveys getActiveMatchingSurveys captureException".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
</script>
</body>
</html>
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const EVENT_TIMERS_KEY = '__timers'
export const AUTOCAPTURE_DISABLED_SERVER_SIDE = '$autocapture_disabled_server_side'
export const HEATMAPS_ENABLED_SERVER_SIDE = '$heatmaps_enabled_server_side'
export const EXCEPTION_CAPTURE_ENABLED_SERVER_SIDE = '$exception_capture_enabled_server_side'
export const EXCEPTION_CAPTURE_ENDPOINT = '$exception_capture_endpoint'
export const EXCEPTION_CAPTURE_ENDPOINT_SUFFIX = '$exception_capture_endpoint_suffix'
export const WEB_VITALS_ENABLED_SERVER_SIDE = '$web_vitals_enabled_server_side'
export const SESSION_RECORDING_ENABLED_SERVER_SIDE = '$session_recording_enabled_server_side'
export const CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE = '$console_log_recording_enabled_server_side'
Expand Down
1 change: 1 addition & 0 deletions src/entrypoints/exception-autocapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const posthogErrorWrappingFunctions = {

if (window) {
;(window as any).posthogErrorWrappingFunctions = posthogErrorWrappingFunctions
;(window as any).parseErrorAsProperties = errorToProperties
}

export default posthogErrorWrappingFunctions
40 changes: 4 additions & 36 deletions src/extensions/exception-autocapture/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,13 @@ import { window } from '../../utils/globals'
import { PostHog } from '../../posthog-core'
import { DecideResponse, Properties } from '../../types'

import { isObject } from '../../utils/type-utils'
import { logger } from '../../utils/logger'
import { EXCEPTION_CAPTURE_ENABLED_SERVER_SIDE, EXCEPTION_CAPTURE_ENDPOINT } from '../../constants'
import { EXCEPTION_CAPTURE_ENABLED_SERVER_SIDE } from '../../constants'
import Config from '../../config'

// TODO: move this to /x/ as default
export const BASE_ERROR_ENDPOINT = '/e/'
const LOGGER_PREFIX = '[Exception Capture]'
const LOGGER_PREFIX = '[Exception Autocapture]'

export class ExceptionObserver {
private _endpointSuffix: string
instance: PostHog
remoteEnabled: boolean | undefined
private originalOnUnhandledRejectionHandler: Window['onunhandledrejection'] | null | undefined = undefined
Expand All @@ -23,17 +19,9 @@ export class ExceptionObserver {
this.instance = instance
this.remoteEnabled = !!this.instance.persistence?.props[EXCEPTION_CAPTURE_ENABLED_SERVER_SIDE]

// TODO: once BASE_ERROR_ENDPOINT is no longer /e/ this can be removed
this._endpointSuffix = this.instance.persistence?.props[EXCEPTION_CAPTURE_ENDPOINT] || BASE_ERROR_ENDPOINT

this.startIfEnabled()
}

get endpoint() {
// Always respect any api_host set by the client config
return this.instance.requestRouter.endpointFor('api', this._endpointSuffix)
}

get isEnabled() {
return this.remoteEnabled ?? false
}
Expand Down Expand Up @@ -68,7 +56,7 @@ export class ExceptionObserver {
}

private startCapturing = () => {
if (!window || !this.isEnabled || this.hasHandlers || (window.onerror as any)?.__POSTHOG_INSTRUMENTED__) {
if (!window || !this.isEnabled || this.hasHandlers || this.isCapturing) {
return
}

Expand Down Expand Up @@ -99,20 +87,11 @@ export class ExceptionObserver {

// store this in-memory in case persistence is disabled
this.remoteEnabled = !!autocaptureExceptionsResponse || false
this._endpointSuffix = isObject(autocaptureExceptionsResponse)
? autocaptureExceptionsResponse.endpoint || BASE_ERROR_ENDPOINT
: BASE_ERROR_ENDPOINT

if (this.instance.persistence) {
this.instance.persistence.register({
[EXCEPTION_CAPTURE_ENABLED_SERVER_SIDE]: this.remoteEnabled,
})
// when we come to moving the endpoint to not /e/
// we'll want that to persist between startup and decide response
// TODO: once BASE_ENDPOINT is no longer /e/ this can be removed
this.instance.persistence.register({
[EXCEPTION_CAPTURE_ENDPOINT]: this._endpointSuffix,
})
}

this.startIfEnabled()
Expand All @@ -125,17 +104,6 @@ export class ExceptionObserver {
this.instance.config.token
}/person/${this.instance.get_distinct_id()}`

this.sendExceptionEvent(errorProperties)
}

/**
* :TRICKY: Make sure we batch these requests
*/
sendExceptionEvent(properties: { [key: string]: any }) {
this.instance.capture('$exception', properties, {
_noTruncate: true,
_batchKey: 'exceptionEvent',
_url: this.endpoint,
})
this.instance.exceptions.sendExceptionEvent(errorProperties)
}
}
9 changes: 1 addition & 8 deletions src/extensions/sentry-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@

import { PostHog } from '../posthog-core'
import { SeverityLevel } from '../types'
import { BASE_ERROR_ENDPOINT } from './exception-autocapture'

// NOTE - we can't import from @sentry/types because it changes frequently and causes clashes
// We only use a small subset of the types, so we can just define the integration overall and use any for the rest
Expand Down Expand Up @@ -126,13 +125,7 @@ export function createEventProcessor(
event.event_id
}

// we take the URL from the exception observer
// so that when we add error specific URL for ingestion
// these errors are sent there too
_posthog.capture('$exception', data, {
_url:
_posthog.exceptionObserver?.endpoint || _posthog.requestRouter.endpointFor('api', BASE_ERROR_ENDPOINT),
})
_posthog.exceptions.sendExceptionEvent(data)

return event
}
Expand Down
18 changes: 18 additions & 0 deletions src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import { TracingHeaders } from './extensions/tracing-headers'
import { ConsentManager } from './consent'
import { ExceptionObserver } from './extensions/exception-autocapture'
import { WebVitalsAutocapture } from './extensions/web-vitals'
import { PostHogExceptions } from './posthog-exceptions'

/*
SIMPLE STYLE GUIDE:
Expand Down Expand Up @@ -242,6 +243,7 @@ export class PostHog {
featureFlags: PostHogFeatureFlags
surveys: PostHogSurveys
toolbar: Toolbar
exceptions: PostHogExceptions
consent: ConsentManager

// These are instance-specific state created after initialisation
Expand Down Expand Up @@ -295,6 +297,7 @@ export class PostHog {
this.scrollManager = new ScrollManager(this)
this.pageViewManager = new PageViewManager(this)
this.surveys = new PostHogSurveys(this)
this.exceptions = new PostHogExceptions(this)
this.rateLimiter = new RateLimiter(this)
this.requestRouter = new RequestRouter(this)
this.consent = new ConsentManager(this)
Expand Down Expand Up @@ -536,6 +539,7 @@ export class PostHog {
this.heatmaps?.afterDecideResponse(response)
this.surveys?.afterDecideResponse(response)
this.webVitalsAutocapture?.afterDecideResponse(response)
this.exceptions?.afterDecideResponse(response)
this.exceptionObserver?.afterDecideResponse(response)
}

Expand Down Expand Up @@ -1811,6 +1815,20 @@ export class PostHog {
return !!this.sessionRecording?.started
}

/** Capture a caught exception manually */
captureException(error: Error, additionalProperties?: Properties): void {
const properties: Properties = isFunction(assignableWindow.parseErrorAsProperties)
? assignableWindow.parseErrorAsProperties([error.message, undefined, undefined, undefined, error])
: {
$exception_type: error.name,
$exception_message: error.message,
$exception_level: 'error',
...additionalProperties,
}

this.exceptions.sendExceptionEvent(properties)
}

/**
* returns a boolean indicating whether the toolbar loaded
* @param toolbarParams
Expand Down
50 changes: 50 additions & 0 deletions src/posthog-exceptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { EXCEPTION_CAPTURE_ENDPOINT_SUFFIX } from './constants'
import { PostHog } from './posthog-core'
import { DecideResponse, Properties } from './types'
import { isObject } from './utils/type-utils'

// TODO: move this to /x/ as default
export const BASE_ERROR_ENDPOINT_SUFFIX = '/e/'

export class PostHogExceptions {
private _endpointSuffix: string

constructor(private readonly instance: PostHog) {
// TODO: once BASE_ERROR_ENDPOINT_SUFFIX is no longer /e/ this can be removed
this._endpointSuffix =
this.instance.persistence?.props[EXCEPTION_CAPTURE_ENDPOINT_SUFFIX] || BASE_ERROR_ENDPOINT_SUFFIX
}

get endpoint() {
// Always respect any api_host set by the client config
return this.instance.requestRouter.endpointFor('api', this._endpointSuffix)
}

afterDecideResponse(response: DecideResponse) {
const autocaptureExceptionsResponse = response.autocaptureExceptions

this._endpointSuffix = isObject(autocaptureExceptionsResponse)
? autocaptureExceptionsResponse.endpoint || BASE_ERROR_ENDPOINT_SUFFIX
: BASE_ERROR_ENDPOINT_SUFFIX

if (this.instance.persistence) {
// when we come to moving the endpoint to not /e/
// we'll want that to persist between startup and decide response
// TODO: once BASE_ENDPOINT is no longer /e/ this can be removed
this.instance.persistence.register({
[EXCEPTION_CAPTURE_ENDPOINT_SUFFIX]: this._endpointSuffix,
})
}
}

/**
* :TRICKY: Make sure we batch these requests
*/
sendExceptionEvent(properties: Properties) {
this.instance.capture('$exception', properties, {
_noTruncate: true,
_batchKey: 'exceptionEvent',
_url: this.endpoint,
})
}
}

0 comments on commit dc66bcf

Please sign in to comment.