From 20e58ae40381abad891023af1fe46cb83c5a8294 Mon Sep 17 00:00:00 2001 From: Liam Cullen Date: Mon, 15 Jun 2026 08:04:54 +1000 Subject: [PATCH 1/2] fix(rock): filter Safari load failure exceptions --- components/providers/posthog-provider.tsx | 7 ++- lib/analytics.test.ts | 58 +++++++++++++++++++++++ lib/analytics.ts | 53 ++++++++++++++++++++- 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 lib/analytics.test.ts diff --git a/components/providers/posthog-provider.tsx b/components/providers/posthog-provider.tsx index a2b763c..645ea4d 100644 --- a/components/providers/posthog-provider.tsx +++ b/components/providers/posthog-provider.tsx @@ -1,6 +1,10 @@ // app/providers.js "use client"; -import { disableAnalytics, initializeAnalytics } from "@/lib/analytics"; +import { + disableAnalytics, + filterBrowserLoadFailureExceptions, + initializeAnalytics, +} from "@/lib/analytics"; import { env } from "@/lib/env"; import { useEffect } from "react"; @@ -21,6 +25,7 @@ export function CSPostHogProvider({ void initializeAnalytics(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: posthogApiPath, + before_send: filterBrowserLoadFailureExceptions, capture_dead_clicks: true, capture_exceptions: { capture_console_errors: true, diff --git a/lib/analytics.test.ts b/lib/analytics.test.ts new file mode 100644 index 0000000..c09a4e1 --- /dev/null +++ b/lib/analytics.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { filterBrowserLoadFailureExceptions } from "./analytics"; + +describe("analytics event filtering", () => { + it("drops stackless Safari load failure exceptions", () => { + const event = { + event: "$exception", + properties: { + $browser: "Safari", + $exception_list: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + }; + + expect(filterBrowserLoadFailureExceptions(event)).toBeNull(); + }); + + it("keeps Safari load failure exceptions with stack frames", () => { + const event = { + event: "$exception", + properties: { + $browser: "Safari", + $exception_list: [ + { + stacktrace: { + frames: [{ filename: "app/page.tsx" }], + }, + type: "TypeError", + value: "Load failed", + }, + ], + }, + }; + + expect(filterBrowserLoadFailureExceptions(event)).toBe(event); + }); + + it("keeps non-Safari load failure exceptions", () => { + const event = { + event: "$exception", + properties: { + $browser: "Chrome", + $exception_list: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + }; + + expect(filterBrowserLoadFailureExceptions(event)).toBe(event); + }); +}); diff --git a/lib/analytics.ts b/lib/analytics.ts index beea4f7..d599bf8 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -1,11 +1,23 @@ "use client"; import { useSyncExternalStore } from "react"; -import type { PostHog, PostHogConfig } from "posthog-js"; +import type { BeforeSendFn, PostHog, PostHogConfig } from "posthog-js"; type CaptureProperties = Record; type GameResult = "lost" | "won"; +interface ExceptionFrame { + filename?: unknown; +} + +interface ExceptionEntry { + stacktrace?: { + frames?: ExceptionFrame[]; + }; + type?: unknown; + value?: unknown; +} + let isAnalyticsEnabled = false; let posthogClientPromise: Promise | null = null; const analyticsEnabledSubscribers = new Set<() => void>(); @@ -41,6 +53,45 @@ export function useAnalyticsEnabled() { ); } +function isExceptionEntry(value: unknown): value is ExceptionEntry { + return typeof value === "object" && value !== null; +} + +function hasStackFrames(exceptionList: ExceptionEntry[]) { + return exceptionList.some((exception) => + exception.stacktrace?.frames?.some((frame) => frame.filename), + ); +} + +function getEventProperty( + properties: object | undefined, + key: string, +): unknown { + if (!properties) return undefined; + + return (properties as Record)[key]; +} + +export const filterBrowserLoadFailureExceptions: BeforeSendFn = (event) => { + if (event?.event !== "$exception") return event; + + const properties = event.properties; + const exceptionList = getEventProperty(properties, "$exception_list"); + + if (!Array.isArray(exceptionList)) return event; + + const normalizedExceptionList = exceptionList.filter(isExceptionEntry); + const isStacklessSafariLoadFailure = + getEventProperty(properties, "$browser") === "Safari" && + normalizedExceptionList.some( + (exception) => + exception.type === "TypeError" && exception.value === "Load failed", + ) && + !hasStackFrames(normalizedExceptionList); + + return isStacklessSafariLoadFailure ? null : event; +}; + export function initializeAnalytics( apiKey: string, options: Partial, From 8fb8c72e70709f7e3d6fa264a9be47b4e0a7e17b Mon Sep 17 00:00:00 2001 From: Liam Cullen Date: Mon, 15 Jun 2026 08:43:49 +1000 Subject: [PATCH 2/2] test(rock): type PostHog exception fixtures --- lib/analytics.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/analytics.test.ts b/lib/analytics.test.ts index c09a4e1..dce0015 100644 --- a/lib/analytics.test.ts +++ b/lib/analytics.test.ts @@ -14,6 +14,7 @@ describe("analytics event filtering", () => { }, ], }, + uuid: "stackless-safari-load-failure", }; expect(filterBrowserLoadFailureExceptions(event)).toBeNull(); @@ -34,6 +35,7 @@ describe("analytics event filtering", () => { }, ], }, + uuid: "safari-load-failure-with-stack", }; expect(filterBrowserLoadFailureExceptions(event)).toBe(event); @@ -51,6 +53,7 @@ describe("analytics event filtering", () => { }, ], }, + uuid: "chrome-load-failure", }; expect(filterBrowserLoadFailureExceptions(event)).toBe(event);