Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: manually capture errors #1374

Merged
merged 17 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 56 additions & 17 deletions cypress/e2e/error-tracking.cy.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,75 @@
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.$exception_source).to.eql(undefined)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor nit: check extra_prop is set too?

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_stack_trace_raw).to.exist
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: confirm this is also wat even am i and not some other autocaptured exception (since all of those will have stack traces too)

})
})
})
})
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 })">
daibhin marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -293,6 +295,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 @@ -534,6 +537,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 @@ -1809,6 +1813,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,
})
}
}
Loading