diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd385f31..bffa87c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: - run: node --test .github/scripts/sift-rankings-agg.test.cjs - run: node --test .github/scripts/fork-yaml-ci.test.cjs - run: node --test .github/scripts/program-logo-server.test.cjs + - run: npx tsx --test scripts/posthog-noise.test.ts - run: npx tsc --noEmit - run: npm run build - run: npm run verify:client-registry diff --git a/scripts/posthog-noise.test.ts b/scripts/posthog-noise.test.ts new file mode 100644 index 00000000..7989835b --- /dev/null +++ b/scripts/posthog-noise.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import type { CaptureResult } from "posthog-js" +import { dropNoisyException } from "../src/lib/posthog-noise" + +function exception(value: string, filenames: string[]): CaptureResult { + return { + uuid: "00000000-0000-4000-8000-000000000001", + event: "$exception", + properties: { + $exception_list: [ + { + value, + stacktrace: { frames: filenames.map((filename) => ({ filename })) }, + }, + ], + }, + } +} + +test("keeps a normal app exception", () => { + const event = exception("Cannot read properties of undefined", ["https://openaffiliate.dev/_next/static/chunks/app.js"]) + assert.equal(dropNoisyException(event), event) +}) + +test("drops Script error, Zalo, postMessage, and extension disconnects", () => { + assert.equal(dropNoisyException(exception("Script error.", [])), null) + assert.equal(dropNoisyException(exception("zaloJSV2 is not defined", [])), null) + assert.equal(dropNoisyException(exception("Error invoking postMessage: Java object is gone", [])), null) + assert.equal(dropNoisyException(exception("Receiving end does not exist", [])), null) +}) + +test("drops a stack that is only the Facebook in-app browser or an extension", () => { + assert.equal(dropNoisyException(exception("TypeError", ["iabjs://facebook/init.js"])), null) + assert.equal(dropNoisyException(exception("TypeError", ["chrome-extension://abc/content.js"])), null) + assert.equal(dropNoisyException(exception("TypeError", ["moz-extension://abc/content.js"])), null) + assert.equal(dropNoisyException(exception("TypeError", ["safari-web-extension:injector.js"])), null) +}) + +test("keeps an app exception even when an extension frame is also on the stack", () => { + const event = exception("Cannot read properties of undefined", [ + "chrome-extension://abc/content.js", + "https://openaffiliate.dev/_next/static/chunks/app.js", + ]) + assert.equal(dropNoisyException(event), event) +}) + +test("does not touch pageviews", () => { + const event: CaptureResult = { + uuid: "00000000-0000-4000-8000-000000000002", + event: "$pageview", + properties: {}, + } + assert.equal(dropNoisyException(event), event) + assert.equal(dropNoisyException(null), null) +}) diff --git a/src/lib/posthog-noise.ts b/src/lib/posthog-noise.ts new file mode 100644 index 00000000..9781bf86 --- /dev/null +++ b/src/lib/posthog-noise.ts @@ -0,0 +1,53 @@ +import type { CaptureResult } from "posthog-js" + +type Frame = { filename?: unknown } + +const NOISE_VALUE_INCLUDES = [ + "zaloJSV2", + "Error invoking postMessage", + "Receiving end does not exist", +] + +function isNoiseFilename(filename: string): boolean { + return ( + filename.startsWith("iabjs://") || + filename.startsWith("chrome-extension://") || + filename.startsWith("moz-extension://") || + filename.startsWith("safari-extension:") || + filename.startsWith("safari-web-extension:") + ) +} + +function framesOf(properties: CaptureResult["properties"]): Frame[] { + const list = properties.$exception_list + const first = Array.isArray(list) ? list[0] : undefined + if (!first || typeof first !== "object") return [] + const stack = (first as { stacktrace?: { frames?: unknown } }).stacktrace + const frames = stack?.frames + return Array.isArray(frames) ? (frames as Frame[]) : [] +} + +function exceptionValue(properties: CaptureResult["properties"]): string { + const list = properties.$exception_list + const first = Array.isArray(list) ? list[0] : undefined + if (!first || typeof first !== "object") return "" + const value = (first as { value?: unknown }).value + return typeof value === "string" ? value : "" +} + +/** + * Drop browser noise before it leaves the page. Real exceptions stay. + * A stack is noise only when every named frame is an in-app browser or + * an extension. One app frame keeps the event. + */ +export function dropNoisyException(event: CaptureResult | null): CaptureResult | null { + if (!event || event.event !== "$exception") return event + const value = exceptionValue(event.properties) + if (value === "Script error.") return null + if (NOISE_VALUE_INCLUDES.some((part) => value.includes(part))) return null + const named = framesOf(event.properties) + .map((frame) => frame.filename) + .filter((filename): filename is string => typeof filename === "string" && filename.length > 0) + if (named.length > 0 && named.every(isNoiseFilename)) return null + return event +} diff --git a/src/lib/posthog.ts b/src/lib/posthog.ts index 33d0092b..2d525015 100644 --- a/src/lib/posthog.ts +++ b/src/lib/posthog.ts @@ -1,4 +1,5 @@ import posthog from "posthog-js"; +import { dropNoisyException } from "@/lib/posthog-noise"; // Single shared PostHog client for the whole app. Init happens inside the // React tree (components/posthog-provider.tsx), NOT in instrumentation-client.ts @@ -32,6 +33,8 @@ export function initPostHog(): void { capture_pageview: false, capture_pageleave: true, autocapture: true, + capture_exceptions: true, + before_send: dropNoisyException, persistence: "localStorage+cookie", // No login on this site — don't spend person profiles on anonymous users. person_profiles: "identified_only",