From 6e51d054a6dd15ccbe37f3a527502495ac3922d3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 22 Aug 2026 15:08:10 -0700 Subject: [PATCH 1/4] fix(analytics): stop dropping client events captured before PostHog init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/_shell/providers/posthog-provider.tsx | 13 +++- .../w/[workflowId]/components/error/index.tsx | 22 +++++- apps/sim/lib/posthog/client.test.ts | 76 ++++++++++++++++++ apps/sim/lib/posthog/client.ts | 78 ++++++++++++++++--- 4 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 apps/sim/lib/posthog/client.test.ts diff --git a/apps/sim/app/_shell/providers/posthog-provider.tsx b/apps/sim/app/_shell/providers/posthog-provider.tsx index d7c0152476f..ab136a8d8a6 100644 --- a/apps/sim/app/_shell/providers/posthog-provider.tsx +++ b/apps/sim/app/_shell/providers/posthog-provider.tsx @@ -4,6 +4,7 @@ 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' const logger = createLogger('PostHogProvider') @@ -18,7 +19,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 }]) => { @@ -88,6 +92,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 +105,7 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { setProvider(() => PHProvider) }) .catch((err) => { + settlePostHogClient(null) logger.error('Failed to load PostHog', { error: err }) }) }, []) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx index 747f1cde0a4..c9c47f592e0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx @@ -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 new Promise((resolve) => setTimeout(resolve, 0)) + +describe('captureClientEvent', () => { + const posthog = createFakePostHog() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('holds an event captured before PostHog initializes, then sends it', async () => { + captureClientEvent('login_page_viewed', {}) + await flush() + + expect(posthog.capture).not.toHaveBeenCalled() + + settlePostHogClient(posthog) + await flush() + + expect(posthog.capture).toHaveBeenCalledWith('login_page_viewed', {}) + }) + + it('sends events captured after initialization', async () => { + captureClientEvent('signup_page_viewed', {}) + await flush() + + expect(posthog.capture).toHaveBeenCalledWith('signup_page_viewed', {}) + }) + + it('reports a caught error through captureException so error tracking sees it', async () => { + const error = new Error('canvas exploded') + + captureClientException(error, { error_boundary: 'workflow_canvas' }) + await flush() + + expect(posthog.captureException).toHaveBeenCalledWith(error, { + error_boundary: 'workflow_canvas', + }) + }) +}) + +describe('captureClientEvent when analytics is disabled', () => { + it('drops events without throwing once the provider settles with null', async () => { + vi.resetModules() + const client = await import('@/lib/posthog/client') + + client.captureClientEvent('login_page_viewed', {}) + client.captureClientException(new Error('boom')) + client.settlePostHogClient(null) + + await expect(flush()).resolves.toBeUndefined() + }) +}) diff --git a/apps/sim/lib/posthog/client.ts b/apps/sim/lib/posthog/client.ts index be04e115ecb..d4ad2e67486 100644 --- a/apps/sim/lib/posthog/client.ts +++ b/apps/sim/lib/posthog/client.ts @@ -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((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( 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) + }) +} + +/** + * 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): void { + whenReady((posthog) => { + posthog.captureException(error, properties) + }) } /** From 4a6cba59dc1d4311c64a7e3657492545f2e51bd2 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 22 Aug 2026 15:35:59 -0700 Subject: [PATCH 2/4] fix(analytics): drop unactionable browser noise from error tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/_shell/providers/posthog-provider.tsx | 9 ++ apps/sim/lib/posthog/exception-filter.test.ts | 85 +++++++++++++++++++ apps/sim/lib/posthog/exception-filter.ts | 84 ++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 apps/sim/lib/posthog/exception-filter.test.ts create mode 100644 apps/sim/lib/posthog/exception-filter.ts diff --git a/apps/sim/app/_shell/providers/posthog-provider.tsx b/apps/sim/app/_shell/providers/posthog-provider.tsx index ab136a8d8a6..095646f29de 100644 --- a/apps/sim/app/_shell/providers/posthog-provider.tsx +++ b/apps/sim/app/_shell/providers/posthog-provider.tsx @@ -5,6 +5,7 @@ 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') @@ -56,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, diff --git a/apps/sim/lib/posthog/exception-filter.test.ts b/apps/sim/lib/posthog/exception-filter.test.ts new file mode 100644 index 00000000000..5a22dc74ae2 --- /dev/null +++ b/apps/sim/lib/posthog/exception-filter.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import type { CaptureResult } from 'posthog-js' +import { describe, expect, it } from 'vitest' +import { dropUnactionableExceptions } from '@/lib/posthog/exception-filter' + +function exceptionEvent(...exceptions: Array<{ type?: string; value?: string }>): CaptureResult { + return { + uuid: 'test-uuid', + event: '$exception', + properties: { $exception_list: exceptions }, + } 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(exceptionEvent({ type: 'Error', value }))).toBeNull() + }) + + it.each(['AbortError', 'Canceled'])('drops the cancellation signal %j', (type) => { + expect(dropUnactionableExceptions(exceptionEvent({ type, value: 'whatever' }))).toBeNull() + }) + + it('keeps a real exception', () => { + const event = exceptionEvent({ + type: 'TypeError', + value: "Cannot read properties of undefined (reading 'id')", + }) + + expect(dropUnactionableExceptions(event)).toBe(event) + }) + + it('keeps a chained exception when only one link is noise', () => { + const event = exceptionEvent( + { 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 = exceptionEvent({ + 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) + }) +}) diff --git a/apps/sim/lib/posthog/exception-filter.ts b/apps/sim/lib/posthog/exception-filter.ts new file mode 100644 index 00000000000..283604a1b8a --- /dev/null +++ b/apps/sim/lib/posthog/exception-filter.ts @@ -0,0 +1,84 @@ +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_EXCEPTION_TYPES = 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 +} + +function isNoise(exception: CapturedException): boolean { + if (typeof exception.type === 'string' && CANCELLATION_EXCEPTION_TYPES.has(exception.type)) { + 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`, and any + * `$exception` whose list is missing or unrecognizable, passes 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 allNoise = exceptions.every( + (exception) => + typeof exception === 'object' && exception !== null && isNoise(exception as CapturedException) + ) + + return allNoise ? null : event +} From 8419c6fbb7ed95b48f50328a2b93b9f804d4f781 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 22 Aug 2026 15:46:10 -0700 Subject: [PATCH 3/4] fix(analytics): match cancellations on error name, not coerced type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/lib/posthog/exception-filter.test.ts | 75 +++++++++++++++++-- apps/sim/lib/posthog/exception-filter.ts | 59 ++++++++++++--- 2 files changed, 117 insertions(+), 17 deletions(-) diff --git a/apps/sim/lib/posthog/exception-filter.test.ts b/apps/sim/lib/posthog/exception-filter.test.ts index 5a22dc74ae2..f2a941310c2 100644 --- a/apps/sim/lib/posthog/exception-filter.test.ts +++ b/apps/sim/lib/posthog/exception-filter.test.ts @@ -5,11 +5,34 @@ import type { CaptureResult } from 'posthog-js' import { describe, expect, it } from 'vitest' import { dropUnactionableExceptions } from '@/lib/posthog/exception-filter' -function exceptionEvent(...exceptions: Array<{ type?: string; value?: string }>): CaptureResult { +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: exceptions }, + properties: { $exception_list: [{ ...exception, mechanism: { handled: true } }] }, } as CaptureResult } @@ -34,15 +57,42 @@ describe('dropUnactionableExceptions', () => { 'ResizeObserver loop limit exceeded', 'Script error.', ])('drops the undiagnosable browser artifact %j', (value) => { - expect(dropUnactionableExceptions(exceptionEvent({ type: 'Error', value }))).toBeNull() + 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() }) - it.each(['AbortError', 'Canceled'])('drops the cancellation signal %j', (type) => { - expect(dropUnactionableExceptions(exceptionEvent({ type, value: 'whatever' }))).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 = exceptionEvent({ + const event = browserRaised({ type: 'TypeError', value: "Cannot read properties of undefined (reading 'id')", }) @@ -50,8 +100,17 @@ describe('dropUnactionableExceptions', () => { 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 = exceptionEvent( + const event = browserRaised( { type: 'AbortError', value: 'signal is aborted without reason' }, { type: 'RangeError', value: 'Maximum call stack size exceeded.' } ) @@ -60,7 +119,7 @@ describe('dropUnactionableExceptions', () => { }) it('keeps an exception whose message merely mentions a filtered one', () => { - const event = exceptionEvent({ + const event = browserRaised({ type: 'TypeError', value: 'Failed to patch ResizeObserver loop completed with undelivered notifications', }) diff --git a/apps/sim/lib/posthog/exception-filter.ts b/apps/sim/lib/posthog/exception-filter.ts index 283604a1b8a..59db5f9fffa 100644 --- a/apps/sim/lib/posthog/exception-filter.ts +++ b/apps/sim/lib/posthog/exception-filter.ts @@ -13,7 +13,7 @@ import type { CaptureResult } from 'posthog-js' * 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_EXCEPTION_TYPES = new Set(['AbortError', 'Canceled']) +const CANCELLATION_ERROR_NAMES = new Set(['AbortError', 'Canceled']) /** * Exception messages that carry no diagnosable content. @@ -41,13 +41,50 @@ const UNDIAGNOSABLE_EXCEPTION_MESSAGES = [ interface CapturedException { type?: unknown value?: unknown + mechanism?: { handled?: unknown } } -function isNoise(exception: CapturedException): boolean { - if (typeof exception.type === 'string' && CANCELLATION_EXCEPTION_TYPES.has(exception.type)) { +/** + * 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() @@ -57,8 +94,9 @@ function isNoise(exception: CapturedException): boolean { /** * `before_send` hook that drops browser noise from error tracking. * - * Fails open in every direction: anything that is not a `$exception`, and any - * `$exception` whose list is missing or unrecognizable, passes through + * 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. * @@ -75,10 +113,13 @@ export function dropUnactionableExceptions(event: CaptureResult | null): Capture const exceptions: unknown = event.properties?.$exception_list if (!Array.isArray(exceptions) || exceptions.length === 0) return event - const allNoise = exceptions.every( - (exception) => - typeof exception === 'object' && exception !== null && isNoise(exception as CapturedException) + 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 allNoise ? null : event + return entries.every(isNoise) ? null : event } From 0ba6c703b66ed3cdfe8196ad28d5b676e1637d94 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 22 Aug 2026 15:48:51 -0700 Subject: [PATCH 4/4] test(analytics): make posthog client tests order-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/lib/posthog/client-disabled.test.ts | 27 +++++++++ apps/sim/lib/posthog/client.test.ts | 59 ++++++-------------- 2 files changed, 45 insertions(+), 41 deletions(-) create mode 100644 apps/sim/lib/posthog/client-disabled.test.ts diff --git a/apps/sim/lib/posthog/client-disabled.test.ts b/apps/sim/lib/posthog/client-disabled.test.ts new file mode 100644 index 00000000000..7e79fa43220 --- /dev/null +++ b/apps/sim/lib/posthog/client-disabled.test.ts @@ -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() + }) +}) diff --git a/apps/sim/lib/posthog/client.test.ts b/apps/sim/lib/posthog/client.test.ts index b427df60fee..6658defa49f 100644 --- a/apps/sim/lib/posthog/client.test.ts +++ b/apps/sim/lib/posthog/client.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import type { PostHog } from 'posthog-js' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { captureClientEvent, captureClientException, @@ -21,56 +21,33 @@ function createFakePostHog() { } as unknown as PostHog } -/** Lets the readiness promise and its continuations settle. */ -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) +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() -describe('captureClientEvent', () => { - const posthog = createFakePostHog() - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('holds an event captured before PostHog initializes, then sends it', async () => { captureClientEvent('login_page_viewed', {}) - await flush() + await Promise.resolve() expect(posthog.capture).not.toHaveBeenCalled() settlePostHogClient(posthog) - await flush() + await vi.waitFor(() => expect(posthog.capture).toHaveBeenCalledWith('login_page_viewed', {})) - expect(posthog.capture).toHaveBeenCalledWith('login_page_viewed', {}) - }) - - it('sends events captured after initialization', async () => { captureClientEvent('signup_page_viewed', {}) - await flush() - - expect(posthog.capture).toHaveBeenCalledWith('signup_page_viewed', {}) - }) + await vi.waitFor(() => expect(posthog.capture).toHaveBeenCalledWith('signup_page_viewed', {})) - it('reports a caught error through captureException so error tracking sees it', async () => { const error = new Error('canvas exploded') - captureClientException(error, { error_boundary: 'workflow_canvas' }) - await flush() - - expect(posthog.captureException).toHaveBeenCalledWith(error, { - error_boundary: 'workflow_canvas', - }) - }) -}) - -describe('captureClientEvent when analytics is disabled', () => { - it('drops events without throwing once the provider settles with null', async () => { - vi.resetModules() - const client = await import('@/lib/posthog/client') - - client.captureClientEvent('login_page_viewed', {}) - client.captureClientException(new Error('boom')) - client.settlePostHogClient(null) - - await expect(flush()).resolves.toBeUndefined() + await vi.waitFor(() => + expect(posthog.captureException).toHaveBeenCalledWith(error, { + error_boundary: 'workflow_canvas', + }) + ) }) })