Skip to content
Merged
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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions scripts/posthog-noise.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
53 changes: 53 additions & 0 deletions src/lib/posthog-noise.ts
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions src/lib/posthog.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down
Loading