diff --git a/bun.lock b/bun.lock index 251af10f31..fab5901d0b 100644 --- a/bun.lock +++ b/bun.lock @@ -508,6 +508,7 @@ "devDependencies": { "tsup": "^8.5.0", "typescript": "^5.8.3", + "vitest": "^3.2.6", }, }, "packages/auth": { diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 22949a102b..fc1327f92b 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -8,7 +8,8 @@ }, "devDependencies": { "tsup": "^8.5.0", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^3.2.6" }, "exports": { ".": { @@ -50,6 +51,7 @@ "dev": "tsup src/index.ts src/server.ts --format cjs,esm --watch --dts", "format": "prettier --write .", "lint": "prettier --check .", + "test": "vitest run", "typecheck": "tsc --noEmit" }, "types": "./dist/index.d.ts" diff --git a/packages/analytics/src/components/provider.tsx b/packages/analytics/src/components/provider.tsx index 5ed7394bec..f6bd0416e4 100644 --- a/packages/analytics/src/components/provider.tsx +++ b/packages/analytics/src/components/provider.tsx @@ -3,6 +3,7 @@ import posthog from 'posthog-js'; import { PostHogProvider as PHProvider } from 'posthog-js/react'; import { useEffect } from 'react'; +import { dropBrowserExtensionExceptions } from '../lib/before-send'; import { PostHogPageView } from './page-view'; interface ProviderProps { @@ -20,6 +21,10 @@ export function AnalyticsProvider({ children, userId, userEmail }: ProviderProps posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, capture_pageview: true, + // Drop browser-extension exception noise (e.g. WebExtension + // `runtime.sendMessage` "Tab not found" errors) before it reaches + // error tracking. None of this originates from our code. + before_send: dropBrowserExtensionExceptions, session_recording: { maskAllInputs: false, maskInputOptions: { diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index cc0ab9862d..027df4be9b 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -29,3 +29,4 @@ export * from './components/provider'; export * from './components/organization-identifier'; export * from './components/server-feature-flags-provider'; export * from './hooks/use-feature-flag'; +export * from './lib/before-send'; diff --git a/packages/analytics/src/lib/before-send.test.ts b/packages/analytics/src/lib/before-send.test.ts new file mode 100644 index 0000000000..a4470a1832 --- /dev/null +++ b/packages/analytics/src/lib/before-send.test.ts @@ -0,0 +1,131 @@ +import type { CaptureResult } from 'posthog-js'; +import { describe, expect, it } from 'vitest'; +import { dropBrowserExtensionExceptions, isBrowserExtensionException } from './before-send'; + +function exceptionEvent(properties: Record): CaptureResult { + return { + event: '$exception', + properties, + } as CaptureResult; +} + +describe('isBrowserExtensionException', () => { + it('flags the runtime.sendMessage "Tab not found" noise from the report', () => { + const event = exceptionEvent({ + $exception_list: [ + { + type: 'Error', + value: 'Invalid call to runtime.sendMessage(). Tab not found.', + }, + ], + }); + + expect(isBrowserExtensionException(event)).toBe(true); + }); + + it('flags exceptions whose stack frames come from an extension scheme', () => { + const event = exceptionEvent({ + $exception_list: [ + { + type: 'TypeError', + value: 'undefined is not an object', + stacktrace: { + frames: [{ filename: 'chrome-extension://abc123/content.js' }], + }, + }, + ], + }); + + expect(isBrowserExtensionException(event)).toBe(true); + }); + + it('flags safari web extension frames', () => { + const event = exceptionEvent({ + $exception_list: [ + { + value: 'boom', + stacktrace: { + frames: [{ filename: 'safari-web-extension://XYZ/injected.js' }], + }, + }, + ], + }); + + expect(isBrowserExtensionException(event)).toBe(true); + }); + + it('flags the flat $exception_message fallback', () => { + const event = exceptionEvent({ + $exception_message: 'Extension context invalidated.', + }); + + expect(isBrowserExtensionException(event)).toBe(true); + }); + + it('does not flag genuine application exceptions', () => { + const event = exceptionEvent({ + $exception_list: [ + { + type: 'Error', + value: 'Cannot read properties of null (reading "id")', + stacktrace: { + frames: [{ filename: 'https://www.trycomp.ai/_next/static/chunk.js' }], + }, + }, + ], + }); + + expect(isBrowserExtensionException(event)).toBe(false); + }); + + it('ignores non-exception events entirely', () => { + const event = { + event: '$pageview', + properties: { + $exception_message: 'runtime.sendMessage failed', + }, + } as CaptureResult; + + expect(isBrowserExtensionException(event)).toBe(false); + }); + + it('handles missing / malformed properties without throwing', () => { + expect(isBrowserExtensionException(exceptionEvent({}))).toBe(false); + expect( + isBrowserExtensionException( + exceptionEvent({ $exception_list: 'not-an-array' }), + ), + ).toBe(false); + expect( + isBrowserExtensionException(exceptionEvent({ $exception_list: [null, 42] })), + ).toBe(false); + }); +}); + +describe('dropBrowserExtensionExceptions', () => { + it('drops extension noise by returning null', () => { + const event = exceptionEvent({ + $exception_list: [{ value: 'Invalid call to runtime.sendMessage(). Tab not found.' }], + }); + + expect(dropBrowserExtensionExceptions(event)).toBeNull(); + }); + + it('passes real exceptions through untouched', () => { + const event = exceptionEvent({ + $exception_list: [{ value: 'Real app error' }], + }); + + expect(dropBrowserExtensionExceptions(event)).toBe(event); + }); + + it('passes non-exception events through untouched', () => { + const event = { event: '$pageview', properties: {} } as CaptureResult; + + expect(dropBrowserExtensionExceptions(event)).toBe(event); + }); + + it('handles a null event', () => { + expect(dropBrowserExtensionExceptions(null)).toBeNull(); + }); +}); diff --git a/packages/analytics/src/lib/before-send.ts b/packages/analytics/src/lib/before-send.ts new file mode 100644 index 0000000000..b117dddc16 --- /dev/null +++ b/packages/analytics/src/lib/before-send.ts @@ -0,0 +1,109 @@ +import type { CaptureResult } from 'posthog-js'; + +/** + * URL schemes that browser extensions inject scripts under. Anything in a + * stack trace pointing at one of these did not originate from our code. + */ +const EXTENSION_URL_SCHEMES = [ + 'chrome-extension://', + 'moz-extension://', + 'safari-extension://', + 'safari-web-extension://', + 'ms-browser-extension://', +] as const; + +/** + * Error messages produced by the WebExtension messaging APIs + * (`runtime.sendMessage`, `runtime.connect`, ...). These are thrown by + * third-party extensions running in the page, never by our own code — we + * don't ship any `chrome.runtime` / `browser.runtime` usage. + * + * Example: "Invalid call to runtime.sendMessage(). Tab not found." + */ +const EXTENSION_MESSAGE_PATTERNS = [ + 'runtime.sendmessage', + 'runtime.connect', + 'chrome.runtime', + 'browser.runtime', + 'extension context invalidated', + 'could not establish connection. receiving end does not exist', + 'the message port closed before a response was received', +] as const; + +interface StackFrame { + filename?: unknown; +} + +interface ExceptionListItem { + value?: unknown; + stacktrace?: { + frames?: unknown; + }; +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function containsExtensionScheme(text: string): boolean { + const lower = text.toLowerCase(); + return EXTENSION_URL_SCHEMES.some((scheme) => lower.includes(scheme)); +} + +function matchesExtensionMessage(text: string): boolean { + const lower = text.toLowerCase(); + return EXTENSION_MESSAGE_PATTERNS.some((pattern) => lower.includes(pattern)); +} + +function frameIsFromExtension(frame: unknown): boolean { + if (typeof frame !== 'object' || frame === null) { + return false; + } + return containsExtensionScheme(asString((frame as StackFrame).filename)); +} + +function exceptionIsFromExtension(item: unknown): boolean { + if (typeof item !== 'object' || item === null) { + return false; + } + const { value, stacktrace } = item as ExceptionListItem; + + if (matchesExtensionMessage(asString(value))) { + return true; + } + + const frames = stacktrace?.frames; + return Array.isArray(frames) && frames.some(frameIsFromExtension); +} + +/** + * Returns true when a captured `$exception` event was injected by a browser + * extension rather than thrown by our application code. + */ +export function isBrowserExtensionException(event: CaptureResult): boolean { + if (event.event !== '$exception') { + return false; + } + + const properties = event.properties ?? {}; + + const exceptionList = properties.$exception_list; + if (Array.isArray(exceptionList) && exceptionList.some(exceptionIsFromExtension)) { + return true; + } + + // Fall back to the flat message property some SDK paths populate. + return matchesExtensionMessage(asString(properties.$exception_message)); +} + +/** + * PostHog `before_send` hook that drops browser-extension exception noise + * (e.g. WebExtension `runtime.sendMessage` "Tab not found" errors) so it never + * reaches error tracking. All other events pass through untouched. + */ +export function dropBrowserExtensionExceptions(event: CaptureResult | null): CaptureResult | null { + if (event && isBrowserExtensionException(event)) { + return null; + } + return event; +} diff --git a/packages/analytics/vitest.config.ts b/packages/analytics/vitest.config.ts new file mode 100644 index 0000000000..6f4078716e --- /dev/null +++ b/packages/analytics/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['src/**/*.test.{ts,tsx}'], + exclude: ['node_modules', 'dist'], + }, +});