diff --git a/apps/sim/app/_shell/providers/posthog-provider.tsx b/apps/sim/app/_shell/providers/posthog-provider.tsx index d7c0152476f..095646f29de 100644 --- a/apps/sim/app/_shell/providers/posthog-provider.tsx +++ b/apps/sim/app/_shell/providers/posthog-provider.tsx @@ -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 }) }) }, []) 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 { + 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 new file mode 100644 index 00000000000..6658defa49f --- /dev/null +++ b/apps/sim/lib/posthog/client.test.ts @@ -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', + }) + ) + }) +}) 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) + }) } /** 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..f2a941310c2 --- /dev/null +++ b/apps/sim/lib/posthog/exception-filter.test.ts @@ -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) + }) +}) diff --git a/apps/sim/lib/posthog/exception-filter.ts b/apps/sim/lib/posthog/exception-filter.ts new file mode 100644 index 00000000000..59db5f9fffa --- /dev/null +++ b/apps/sim/lib/posthog/exception-filter.ts @@ -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 +}