Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/analytics/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
},
"devDependencies": {
"tsup": "^8.5.0",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vitest": "^3.2.6"
},
"exports": {
".": {
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions packages/analytics/src/components/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions packages/analytics/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
131 changes: 131 additions & 0 deletions packages/analytics/src/lib/before-send.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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();
});
});
109 changes: 109 additions & 0 deletions packages/analytics/src/lib/before-send.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions packages/analytics/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
},
});