Skip to content

Commit 4a6cba5

Browse files
committed
fix(analytics): drop unactionable browser noise from error tracking
ResizeObserver loop notices were 92% of everything client-side exception autocapture reported. They are specified behaviour, not faults — the observer defers the remaining notifications to the next frame — and they carry no stack that points anywhere. Alongside opaque cross-origin "Script error." reports and cancellation signals (fetch AbortError, Monaco's CancellationError), they bury the handful of real crashes. Filters them in before_send so they never leave the browser. Fails open on anything unrecognized: a wrong guess here would delete product analytics, not just over-report.
1 parent 6e51d05 commit 4a6cba5

3 files changed

Lines changed: 178 additions & 0 deletions

File tree

apps/sim/app/_shell/providers/posthog-provider.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
55
import type { PostHog } from 'posthog-js'
66
import { getEnv, isTruthy, publicEnvMissingAtModuleInit } from '@/lib/core/config/env'
77
import { settlePostHogClient } from '@/lib/posthog/client'
8+
import { dropUnactionableExceptions } from '@/lib/posthog/exception-filter'
89

910
const logger = createLogger('PostHogProvider')
1011

@@ -56,6 +57,14 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
5657
capture_unhandled_rejections: true,
5758
capture_console_errors: false,
5859
},
60+
/**
61+
* Drops the browser artifacts that autocapture cannot help but
62+
* see — resize-loop notices, opaque cross-origin failures, and
63+
* cancelled requests. Filtering here rather than with a PostHog
64+
* suppression rule keeps the list reviewable in the diff and stops
65+
* the events before they leave the browser.
66+
*/
67+
before_send: dropUnactionableExceptions,
5968
disable_session_recording: true,
6069
session_recording: {
6170
maskAllInputs: false,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { CaptureResult } from 'posthog-js'
5+
import { describe, expect, it } from 'vitest'
6+
import { dropUnactionableExceptions } from '@/lib/posthog/exception-filter'
7+
8+
function exceptionEvent(...exceptions: Array<{ type?: string; value?: string }>): CaptureResult {
9+
return {
10+
uuid: 'test-uuid',
11+
event: '$exception',
12+
properties: { $exception_list: exceptions },
13+
} as CaptureResult
14+
}
15+
16+
describe('dropUnactionableExceptions', () => {
17+
it('passes through events that are not exceptions', () => {
18+
const event = {
19+
uuid: 'test-uuid',
20+
event: 'block_added',
21+
properties: { block_type: 'agent' },
22+
} as CaptureResult
23+
24+
expect(dropUnactionableExceptions(event)).toBe(event)
25+
})
26+
27+
it('passes through a null event from an earlier hook', () => {
28+
expect(dropUnactionableExceptions(null)).toBeNull()
29+
})
30+
31+
it.each([
32+
'ResizeObserver loop completed with undelivered notifications.',
33+
'ResizeObserver loop completed with undelivered notifications',
34+
'ResizeObserver loop limit exceeded',
35+
'Script error.',
36+
])('drops the undiagnosable browser artifact %j', (value) => {
37+
expect(dropUnactionableExceptions(exceptionEvent({ type: 'Error', value }))).toBeNull()
38+
})
39+
40+
it.each(['AbortError', 'Canceled'])('drops the cancellation signal %j', (type) => {
41+
expect(dropUnactionableExceptions(exceptionEvent({ type, value: 'whatever' }))).toBeNull()
42+
})
43+
44+
it('keeps a real exception', () => {
45+
const event = exceptionEvent({
46+
type: 'TypeError',
47+
value: "Cannot read properties of undefined (reading 'id')",
48+
})
49+
50+
expect(dropUnactionableExceptions(event)).toBe(event)
51+
})
52+
53+
it('keeps a chained exception when only one link is noise', () => {
54+
const event = exceptionEvent(
55+
{ type: 'AbortError', value: 'signal is aborted without reason' },
56+
{ type: 'RangeError', value: 'Maximum call stack size exceeded.' }
57+
)
58+
59+
expect(dropUnactionableExceptions(event)).toBe(event)
60+
})
61+
62+
it('keeps an exception whose message merely mentions a filtered one', () => {
63+
const event = exceptionEvent({
64+
type: 'TypeError',
65+
value: 'Failed to patch ResizeObserver loop completed with undelivered notifications',
66+
})
67+
68+
expect(dropUnactionableExceptions(event)).toBe(event)
69+
})
70+
71+
it.each([
72+
['a missing list', undefined],
73+
['an empty list', []],
74+
['a non-array list', 'not-an-array'],
75+
['unrecognizable entries', [null, 'string-entry']],
76+
])('fails open on %s', (_label, $exception_list) => {
77+
const event = {
78+
uuid: 'test-uuid',
79+
event: '$exception',
80+
properties: { $exception_list },
81+
} as CaptureResult
82+
83+
expect(dropUnactionableExceptions(event)).toBe(event)
84+
})
85+
})
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { CaptureResult } from 'posthog-js'
2+
3+
/**
4+
* Exception types that only ever mean "something was cancelled".
5+
*
6+
* `AbortError` is what a fetch rejects with once its `AbortSignal` fires —
7+
* React Query aborts in-flight queries on unmount and on refetch, so this is
8+
* routine teardown. `Canceled` is Monaco's `CancellationError`
9+
* (`monaco-editor/esm/vs/base/common/errors.js` sets both `name` and `message`
10+
* to the bare string), raised whenever a language-service request is superseded
11+
* by a newer keystroke.
12+
*
13+
* Neither can be acted on: there is no defect to fix and no user impact, but
14+
* both fire often enough per session to bury real crashes in the issue list.
15+
*/
16+
const CANCELLATION_EXCEPTION_TYPES = new Set(['AbortError', 'Canceled'])
17+
18+
/**
19+
* Exception messages that carry no diagnosable content.
20+
*
21+
* `ResizeObserver loop …` is the browser reporting that a resize callback
22+
* dirtied layout again before delivery. It is specified behaviour, not an
23+
* error: the observer simply defers the remaining notifications to the next
24+
* frame. It has no stack that points anywhere useful and fires constantly in
25+
* resizable/canvas UIs — it was 92% of everything captured in the first days of
26+
* error tracking. Both the current wording and the older `loop limit exceeded`
27+
* spelling are matched.
28+
*
29+
* `Script error.` is what `window.onerror` reports for a cross-origin script
30+
* served without CORS headers. The browser withholds the message, the file, and
31+
* the stack, so nothing about it is recoverable.
32+
*
33+
* Matched by prefix because browsers disagree about the trailing period.
34+
*/
35+
const UNDIAGNOSABLE_EXCEPTION_MESSAGES = [
36+
'ResizeObserver loop completed with undelivered notifications',
37+
'ResizeObserver loop limit exceeded',
38+
'Script error.',
39+
]
40+
41+
interface CapturedException {
42+
type?: unknown
43+
value?: unknown
44+
}
45+
46+
function isNoise(exception: CapturedException): boolean {
47+
if (typeof exception.type === 'string' && CANCELLATION_EXCEPTION_TYPES.has(exception.type)) {
48+
return true
49+
}
50+
51+
if (typeof exception.value !== 'string') return false
52+
const message = exception.value.trim()
53+
54+
return UNDIAGNOSABLE_EXCEPTION_MESSAGES.some((prefix) => message.startsWith(prefix))
55+
}
56+
57+
/**
58+
* `before_send` hook that drops browser noise from error tracking.
59+
*
60+
* Fails open in every direction: anything that is not a `$exception`, and any
61+
* `$exception` whose list is missing or unrecognizable, passes through
62+
* untouched. This runs on **every** captured event, so a filter that guessed
63+
* wrong would silently delete product analytics rather than merely over-report.
64+
*
65+
* A chained exception is dropped only when *every* link is noise — one benign
66+
* link must not hide a real error it was raised alongside.
67+
*
68+
* @param event - The event PostHog is about to send, or `null` if an earlier
69+
* hook already dropped it.
70+
* @returns The event to send, or `null` to drop it.
71+
*/
72+
export function dropUnactionableExceptions(event: CaptureResult | null): CaptureResult | null {
73+
if (!event || event.event !== '$exception') return event
74+
75+
const exceptions: unknown = event.properties?.$exception_list
76+
if (!Array.isArray(exceptions) || exceptions.length === 0) return event
77+
78+
const allNoise = exceptions.every(
79+
(exception) =>
80+
typeof exception === 'object' && exception !== null && isNoise(exception as CapturedException)
81+
)
82+
83+
return allNoise ? null : event
84+
}

0 commit comments

Comments
 (0)