fix(analytics): stop dropping client events captured before PostHog init (#6988)

* fix(analytics): stop dropping client events captured before PostHog init

posthog.capture is a no-op until init() has run — its body sits behind
if (this.__loaded) with no buffer and no warning. PostHogProvider reaches
init through two dynamic imports while a caller needs only one, so
mount-time events and error-boundary crash reports during first paint
landed in that dead window and were discarded silently.

Also routes the workflow canvas error boundary through captureException so
its crashes reach error tracking with a parsed stack.

* 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.

* fix(analytics): match cancellations on error name, not coerced type

Two holes in the exception filter, both found reading PostHog's coercers
rather than trusting the shape.

The DOMException coercer always reports type "DOMException" and folds the
name into the value as "AbortError: signal is aborted without reason", so
matching AbortError on type alone never fired for a real aborted fetch —
the filter was dead code for exactly the events it was written for. Match
the error name wherever the coercer put it.

The filter also applied to deliberate captureException reports, which our
error boundaries make. Only exceptions the browser raised itself
(mechanism.handled === false) are now eligible; a report we chose to send
is never dropped.

* test(analytics): make posthog client tests order-independent

Readiness is module-level and settles once, so the capture and exception
cases silently depended on an earlier case having settled the gate and
failed when run in isolation. Walk the lifecycle in one case instead.

Moves the disabled-analytics case to its own file, where vitest's per-file
isolation supplies an unsettled gate — dropping the vi.resetModules() and
dynamic import the repo's testing conventions rule out. Replaces the
hand-rolled setTimeout flush with vi.waitFor.
This commit is contained in:
Vikhyath Mondreti
2026-08-22 16:20:55 -07:00
committed by GitHub
parent b654bc11cd
commit 2c61b2989c
7 changed files with 455 additions and 16 deletions
@@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { PostHog } from 'posthog-js'
import { getEnv, isTruthy, publicEnvMissingAtModuleInit } from '@/lib/core/config/env'
import { settlePostHogClient } from '@/lib/posthog/client'
import { dropUnactionableExceptions } from '@/lib/posthog/exception-filter'
const logger = createLogger('PostHogProvider')
@@ -18,7 +20,10 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
const posthogEnabled = getEnv('NEXT_PUBLIC_POSTHOG_ENABLED')
const posthogKey = getEnv('NEXT_PUBLIC_POSTHOG_KEY')
if (!isTruthy(posthogEnabled) || !posthogKey) return
if (!isTruthy(posthogEnabled) || !posthogKey) {
settlePostHogClient(null)
return
}
Promise.all([import('posthog-js'), import('posthog-js/react')])
.then(([posthogModule, { PostHogProvider: PHProvider }]) => {
@@ -52,6 +57,14 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
capture_unhandled_rejections: true,
capture_console_errors: false,
},
/**
* Drops the browser artifacts that autocapture cannot help but
* see — resize-loop notices, opaque cross-origin failures, and
* cancelled requests. Filtering here rather than with a PostHog
* suppression rule keeps the list reviewable in the diff and stops
* the events before they leave the browser.
*/
before_send: dropUnactionableExceptions,
disable_session_recording: true,
session_recording: {
maskAllInputs: false,
@@ -88,6 +101,12 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
persistence: 'localStorage+cookie',
})
}
/**
* Releases anything captured while the imports above were in flight.
* Must run after `init`, since `capture` is a silent no-op until then.
*/
settlePostHogClient(posthog)
if (publicEnvMissingAtModuleInit) {
posthog.capture('runtime_env_missing_at_module_init')
}
@@ -95,6 +114,7 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
setProvider(() => PHProvider)
})
.catch((err) => {
settlePostHogClient(null)
logger.error('Failed to load PostHog', { error: err })
})
}, [])
@@ -6,7 +6,7 @@ import { RefreshCw } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { truncate } from '@sim/utils/string'
import { ReactFlowProvider } from 'reactflow'
import { captureClientEvent } from '@/lib/posthog/client'
import { captureClientEvent, captureClientException } from '@/lib/posthog/client'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
@@ -102,9 +102,20 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
* — leaving an intermittent failure with no evidence to diagnose from.
* `error.name` is carried separately from the message because it is what
* separates the failure classes from each other.
*
* Reported twice, to two different consumers. `captureException` is what
* reaches PostHog Error Tracking: it parses the stack into `$exception_list`,
* which is what groups these into an issue and links the session replay — a
* custom event carrying the message as a string property is invisible there.
* The named event stays because it answers a different question, "how often
* does the canvas fall over", against a stable name that survives the
* error tracker's own grouping and resolution.
*/
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const componentStack = errorInfo.componentStack ?? undefined
const reportedComponentStack = componentStack
? truncate(componentStack, MAX_REPORTED_COMPONENT_STACK)
: undefined
logger.error('Workflow canvas crashed', {
name: error.name,
@@ -113,12 +124,15 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
componentStack,
})
captureClientException(error, {
error_boundary: 'workflow_canvas',
component_stack: reportedComponentStack,
})
captureClientEvent('workflow_canvas_crashed', {
error_name: error.name,
error_message: error.message,
component_stack: componentStack
? truncate(componentStack, MAX_REPORTED_COMPONENT_STACK)
: undefined,
component_stack: reportedComponentStack,
})
}
@@ -0,0 +1,27 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
captureClientEvent,
captureClientException,
settlePostHogClient,
} from '@/lib/posthog/client'
/**
* Lives in its own file because readiness is module-level and settles once.
* Vitest isolates the module registry per file, so this gets an unsettled gate
* to settle with `null` without `vi.resetModules()` and a dynamic import.
*/
describe('client capture when analytics is disabled', () => {
it('drops events instead of sending them, without throwing', async () => {
settlePostHogClient(null)
expect(() => captureClientEvent('login_page_viewed', {})).not.toThrow()
expect(() => captureClientException(new Error('boom'))).not.toThrow()
// Lets the gate's continuations run; a missing catch would surface here as
// an unhandled rejection and fail the test.
await Promise.resolve()
})
})
+53
View File
@@ -0,0 +1,53 @@
/**
* @vitest-environment node
*/
import type { PostHog } from 'posthog-js'
import { describe, expect, it, vi } from 'vitest'
import {
captureClientEvent,
captureClientException,
settlePostHogClient,
} from '@/lib/posthog/client'
/**
* Stands in for the initialized singleton. `capture` exists on the real
* instance long before `init` runs, which is what made the dropped-event bug
* invisible to a `typeof posthog.capture === 'function'` guard.
*/
function createFakePostHog() {
return {
capture: vi.fn(),
captureException: vi.fn(),
} as unknown as PostHog
}
describe('client capture', () => {
/**
* Readiness is module-level and settles exactly once, so the whole lifecycle
* runs as a single case. Split across separate cases, each one after the
* first would silently depend on an earlier case having settled the gate, and
* would fail when run in isolation or reordered.
*/
it('holds events until PostHog initializes, then sends them and everything after', async () => {
const posthog = createFakePostHog()
captureClientEvent('login_page_viewed', {})
await Promise.resolve()
expect(posthog.capture).not.toHaveBeenCalled()
settlePostHogClient(posthog)
await vi.waitFor(() => expect(posthog.capture).toHaveBeenCalledWith('login_page_viewed', {}))
captureClientEvent('signup_page_viewed', {})
await vi.waitFor(() => expect(posthog.capture).toHaveBeenCalledWith('signup_page_viewed', {}))
const error = new Error('canvas exploded')
captureClientException(error, { error_boundary: 'workflow_canvas' })
await vi.waitFor(() =>
expect(posthog.captureException).toHaveBeenCalledWith(error, {
error_boundary: 'workflow_canvas',
})
)
})
})
+67 -11
View File
@@ -1,11 +1,54 @@
import type { PostHog } from 'posthog-js'
import type { PostHogEventMap, PostHogEventName } from '@/lib/posthog/events'
/**
* Resolves with the initialized PostHog instance, or `null` when analytics is
* disabled or the library failed to load. Settled exactly once by
* {@link settlePostHogClient}, which `PostHogProvider` calls on every branch.
*
* This gate exists because `posthog.capture` is a hard no-op until
* `posthog.init()` has run: its entire body sits behind `if (this.__loaded)`,
* with no buffer and no warning, so a call made before init is discarded
* silently. `PostHogProvider` reaches init through two dynamic imports
* (`posthog-js` and `posthog-js/react`), while a caller only needs `posthog-js`
* — so anything captured on mount resolves first and lands in that dead window.
* Mount-time events (`login_page_viewed`, `landing_page_viewed`) and, worst of
* all, a crash report from an error boundary that fired during first paint were
* the events most reliably lost.
*/
let settlePostHog!: (instance: PostHog | null) => void
const postHogReady = new Promise<PostHog | null>((resolve) => {
settlePostHog = resolve
})
/**
* Publishes the outcome of PostHog initialization to the capture helpers.
* Called only by `PostHogProvider`. Repeat calls are no-ops.
*
* @param instance - The initialized instance, or `null` when analytics is off.
*/
export function settlePostHogClient(instance: PostHog | null): void {
settlePostHog(instance)
}
/**
* Runs `send` once PostHog is ready, swallowing everything. Analytics must
* never surface as an unhandled rejection — these helpers are called from
* error-reporting paths, where a throw would be captured as its own exception.
*/
function whenReady(send: (posthog: PostHog) => void): void {
postHogReady
.then((posthog) => {
if (posthog) send(posthog)
})
.catch(() => {})
}
/**
* Capture a client-side PostHog event from a non-React context (e.g. Zustand stores).
*
* Uses the same dynamic `import('posthog-js')` pattern as `session-provider.tsx`.
* Fully fire-and-forget — never throws, never blocks.
* Fully fire-and-forget — never throws, never blocks. Events captured before
* PostHog finishes initializing are held until it does rather than dropped.
*
* React components should use {@link captureEvent} with the `posthog` instance from `usePostHog()`.
*
@@ -16,15 +59,28 @@ export function captureClientEvent<E extends PostHogEventName>(
event: E,
properties: PostHogEventMap[E]
): void {
import('posthog-js')
.then(({ default: posthog }) => {
try {
if (typeof posthog.capture === 'function') {
posthog.capture(event, properties)
}
} catch {}
})
.catch(() => {})
whenReady((posthog) => {
posthog.capture(event, properties as Record<string, unknown>)
})
}
/**
* Report a caught error to PostHog Error Tracking.
*
* This is what puts a failure in front of the error tracker: `captureException`
* emits a `$exception` event carrying `$exception_list` — the parsed type,
* message, and stack frames that error tracking groups into an issue and links
* to a session replay. A custom event with the message copied into a string
* property looks equivalent on a dashboard but is invisible to that product,
* carries no stack, and cannot be grouped or resolved.
*
* @param error - The caught value. Coerced by PostHog into an exception list.
* @param properties - Extra context merged onto the `$exception` event.
*/
export function captureClientException(error: unknown, properties?: Record<string, unknown>): void {
whenReady((posthog) => {
posthog.captureException(error, properties)
})
}
/**
@@ -0,0 +1,144 @@
/**
* @vitest-environment node
*/
import type { CaptureResult } from 'posthog-js'
import { describe, expect, it } from 'vitest'
import { dropUnactionableExceptions } from '@/lib/posthog/exception-filter'
interface TestException {
type?: string
value?: string
mechanism?: { handled?: boolean }
}
/** Mirrors what PostHog's `window.onerror` / `unhandledrejection` wrappers build. */
function browserRaised(...exceptions: TestException[]): CaptureResult {
const [head, ...rest] = exceptions
return {
uuid: 'test-uuid',
event: '$exception',
properties: {
$exception_list: [
{ ...head, mechanism: { handled: false } },
// PostHog forces chained cause links to handled: true regardless of the source.
...rest.map((exception) => ({ ...exception, mechanism: { handled: true } })),
],
},
} as CaptureResult
}
/** Mirrors what `posthog.captureException` builds — a deliberate report. */
function deliberatelyReported(exception: TestException): CaptureResult {
return {
uuid: 'test-uuid',
event: '$exception',
properties: { $exception_list: [{ ...exception, mechanism: { handled: true } }] },
} as CaptureResult
}
describe('dropUnactionableExceptions', () => {
it('passes through events that are not exceptions', () => {
const event = {
uuid: 'test-uuid',
event: 'block_added',
properties: { block_type: 'agent' },
} as CaptureResult
expect(dropUnactionableExceptions(event)).toBe(event)
})
it('passes through a null event from an earlier hook', () => {
expect(dropUnactionableExceptions(null)).toBeNull()
})
it.each([
'ResizeObserver loop completed with undelivered notifications.',
'ResizeObserver loop completed with undelivered notifications',
'ResizeObserver loop limit exceeded',
'Script error.',
])('drops the undiagnosable browser artifact %j', (value) => {
expect(dropUnactionableExceptions(browserRaised({ type: 'Error', value }))).toBeNull()
})
it('drops a cancellation whose name is the coerced type', () => {
expect(
dropUnactionableExceptions(browserRaised({ type: 'Canceled', value: 'Canceled' }))
).toBeNull()
})
/**
* The shape a real aborted `fetch` produces: PostHog's DOMException coercer
* reports type `DOMException` and folds the name into the value, so a filter
* that only tested `type` would let every one of these through.
*/
it('drops a cancellation whose name is folded into a DOMException value', () => {
expect(
dropUnactionableExceptions(
browserRaised({
type: 'DOMException',
value: 'AbortError: signal is aborted without reason',
})
)
).toBeNull()
})
it('keeps a DOMException that is not a cancellation', () => {
const event = browserRaised({
type: 'DOMException',
value: "NotFoundError: Failed to execute 'removeChild' on 'Node'",
})
expect(dropUnactionableExceptions(event)).toBe(event)
})
it('keeps a real exception', () => {
const event = browserRaised({
type: 'TypeError',
value: "Cannot read properties of undefined (reading 'id')",
})
expect(dropUnactionableExceptions(event)).toBe(event)
})
it('keeps a deliberately reported exception even when it looks like noise', () => {
const event = deliberatelyReported({
type: 'AbortError',
value: 'signal is aborted without reason',
})
expect(dropUnactionableExceptions(event)).toBe(event)
})
it('keeps a chained exception when only one link is noise', () => {
const event = browserRaised(
{ type: 'AbortError', value: 'signal is aborted without reason' },
{ type: 'RangeError', value: 'Maximum call stack size exceeded.' }
)
expect(dropUnactionableExceptions(event)).toBe(event)
})
it('keeps an exception whose message merely mentions a filtered one', () => {
const event = browserRaised({
type: 'TypeError',
value: 'Failed to patch ResizeObserver loop completed with undelivered notifications',
})
expect(dropUnactionableExceptions(event)).toBe(event)
})
it.each([
['a missing list', undefined],
['an empty list', []],
['a non-array list', 'not-an-array'],
['unrecognizable entries', [null, 'string-entry']],
])('fails open on %s', (_label, $exception_list) => {
const event = {
uuid: 'test-uuid',
event: '$exception',
properties: { $exception_list },
} as CaptureResult
expect(dropUnactionableExceptions(event)).toBe(event)
})
})
+125
View File
@@ -0,0 +1,125 @@
import type { CaptureResult } from 'posthog-js'
/**
* Exception types that only ever mean "something was cancelled".
*
* `AbortError` is what a fetch rejects with once its `AbortSignal` fires —
* React Query aborts in-flight queries on unmount and on refetch, so this is
* routine teardown. `Canceled` is Monaco's `CancellationError`
* (`monaco-editor/esm/vs/base/common/errors.js` sets both `name` and `message`
* to the bare string), raised whenever a language-service request is superseded
* by a newer keystroke.
*
* Neither can be acted on: there is no defect to fix and no user impact, but
* both fire often enough per session to bury real crashes in the issue list.
*/
const CANCELLATION_ERROR_NAMES = new Set(['AbortError', 'Canceled'])
/**
* Exception messages that carry no diagnosable content.
*
* `ResizeObserver loop …` is the browser reporting that a resize callback
* dirtied layout again before delivery. It is specified behaviour, not an
* error: the observer simply defers the remaining notifications to the next
* frame. It has no stack that points anywhere useful and fires constantly in
* resizable/canvas UIs — it was 92% of everything captured in the first days of
* error tracking. Both the current wording and the older `loop limit exceeded`
* spelling are matched.
*
* `Script error.` is what `window.onerror` reports for a cross-origin script
* served without CORS headers. The browser withholds the message, the file, and
* the stack, so nothing about it is recoverable.
*
* Matched by prefix because browsers disagree about the trailing period.
*/
const UNDIAGNOSABLE_EXCEPTION_MESSAGES = [
'ResizeObserver loop completed with undelivered notifications',
'ResizeObserver loop limit exceeded',
'Script error.',
]
interface CapturedException {
type?: unknown
value?: unknown
mechanism?: { handled?: unknown }
}
/**
* Whether the browser raised this itself, rather than us reporting it on purpose.
*
* PostHog's `window.onerror` and `unhandledrejection` wrappers both build their
* exception with `mechanism.handled: false`, while `captureException` — the call
* our error boundaries make — builds with `true`. Only the browser's own reports
* are eligible for filtering: a deliberate report means someone decided the
* failure was worth knowing about, and dropping it would repeat the silent loss
* this filter's sibling gate exists to prevent.
*
* Read from the first entry only. When an error carries a `cause`, PostHog
* appends the chained links with `handled: true` regardless of how the original
* was raised, so the head of the list is the one that reflects the source.
*/
function isBrowserRaised(exceptions: CapturedException[]): boolean {
return exceptions[0]?.mechanism?.handled === false
}
/**
* Whether this is a cancellation, testing both shapes PostHog can produce.
*
* Which field carries the error's `name` depends on which coercer ran. A plain
* `Error` keeps its `name` as `type`, so Monaco's `CancellationError` arrives as
* type `Canceled`. A `DOMException` — what `fetch` rejects with when its signal
* fires — always coerces to type `DOMException`, with the name folded into the
* front of the value as `"AbortError: signal is aborted without reason"`.
* Matching on `type` alone therefore misses every real aborted request.
*/
function isCancellation(exception: CapturedException): boolean {
if (typeof exception.type === 'string' && CANCELLATION_ERROR_NAMES.has(exception.type)) {
return true
}
if (typeof exception.value !== 'string') return false
return CANCELLATION_ERROR_NAMES.has(exception.value.split(':', 1)[0])
}
function isNoise(exception: CapturedException): boolean {
if (isCancellation(exception)) return true
if (typeof exception.value !== 'string') return false
const message = exception.value.trim()
return UNDIAGNOSABLE_EXCEPTION_MESSAGES.some((prefix) => message.startsWith(prefix))
}
/**
* `before_send` hook that drops browser noise from error tracking.
*
* Fails open in every direction: anything that is not a `$exception`, any
* `$exception` whose list is missing or unrecognizable, and anything we
* reported deliberately rather than caught from the browser, all pass through
* untouched. This runs on **every** captured event, so a filter that guessed
* wrong would silently delete product analytics rather than merely over-report.
*
* A chained exception is dropped only when *every* link is noise — one benign
* link must not hide a real error it was raised alongside.
*
* @param event - The event PostHog is about to send, or `null` if an earlier
* hook already dropped it.
* @returns The event to send, or `null` to drop it.
*/
export function dropUnactionableExceptions(event: CaptureResult | null): CaptureResult | null {
if (!event || event.event !== '$exception') return event
const exceptions: unknown = event.properties?.$exception_list
if (!Array.isArray(exceptions) || exceptions.length === 0) return event
const entries: CapturedException[] = exceptions.filter(
(exception): exception is CapturedException =>
typeof exception === 'object' && exception !== null
)
if (entries.length !== exceptions.length) return event
if (!isBrowserRaised(entries)) return event
return entries.every(isNoise) ? null : event
}