From 5decdae41ef847ed5aa1396f6ec32dc69001d27b Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 2 Oct 2026 09:03:08 +0200 Subject: [PATCH] fix(analytics): drop chunk-load errors from PostHog error tracking After a deploy, a stale tab's programmatic router.push() to a lazy route fails to load the removed chunk. chunkReload handles it (reload or message), but vue-router still rejects the push and posthog-js reports the rejection as unhandled. - add dropChunkLoadErrors before_send: drops $exception events whose entries are all chunk-load errors (reuses isChunkLoadError) - before_send is now [maskUrlCodes, dropChunkLoadErrors] - tests for dropped, kept (other, mixed) and non-exception events - docs/analytics.md notes the filter Closes PRUNplanner/frontend#554 Co-Authored-By: Claude Opus 5.5 --- docs/analytics.md | 4 ++ src/lib/analytics/usePostHog.ts | 16 ++++++- src/tests/lib/analytics/usePostHog.test.ts | 53 +++++++++++++++++++++- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index e5e432d81..2ffc7a216 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -182,6 +182,10 @@ exception carries `route_name` too. `:code` in every property of every event, nested ones included (`$current_url`, `$pathname`, `$referrer`, `$initial_*`, exceptions, session replay snapshots). +- Chunk-load errors are not sent: `before_send` + (`dropChunkLoadErrors`) drops an exception whose entries are all + chunk-load errors. `chunkReload` handles them, but a programmatic + `router.push()` still rejects and posthog-js would report it as unhandled. ## Person properties diff --git a/src/lib/analytics/usePostHog.ts b/src/lib/analytics/usePostHog.ts index 1c344d883..0f806ec1f 100644 --- a/src/lib/analytics/usePostHog.ts +++ b/src/lib/analytics/usePostHog.ts @@ -5,6 +5,7 @@ import { useAnalyticsConsent } from "@/lib/analytics/useAnalyticsConsent"; // Util import { redact } from "@/util/data"; +import { isChunkLoadError } from "@/lib/chunkReload"; // Types & Interfaces import type { BeforeSendFn, PostHog, Properties } from "posthog-js"; @@ -50,6 +51,19 @@ export const maskUrlCodes: BeforeSendFn = (event) => { }; }; +/** + * before_send: drops exceptions that are only chunk-load errors. chunkReload + * already handles those (reload or message), but vue-router still rejects + * the router.push() that hit them, and posthog-js reports that rejection as + * unhandled after every deploy. + */ +export const dropChunkLoadErrors: BeforeSendFn = (event) => { + if (event?.event !== "$exception") return event; + const list = event.properties.$exception_list; + if (!Array.isArray(list) || !list.length) return event; + return list.every((entry) => isChunkLoadError(entry?.value)) ? null : event; +}; + // storage keys PostHog writes: its own, the opt-out flag and survey state const POSTHOG_STORAGE_KEY = /^((__)?ph_|seenSurvey_|inProgressSurvey_|lastSeenSurveyDate$)/; @@ -169,7 +183,7 @@ async function startAnalytics(): Promise { // no cookie persistence: "localStorage", respect_dnt: true, - before_send: maskUrlCodes, + before_send: [maskUrlCodes, dropChunkLoadErrors], }); // register global versions diff --git a/src/tests/lib/analytics/usePostHog.test.ts b/src/tests/lib/analytics/usePostHog.test.ts index 71b79c519..76b255308 100644 --- a/src/tests/lib/analytics/usePostHog.test.ts +++ b/src/tests/lib/analytics/usePostHog.test.ts @@ -502,12 +502,16 @@ describe("usePostHog", () => { ) => ({ uuid: "u", event: name, properties, ...extra }); it("is passed to posthog.init as before_send", async () => { - const { maskUrlCodes } = await load({ consent: "granted" }); + const { maskUrlCodes, dropChunkLoadErrors } = await load({ + consent: "granted", + }); await settle(); expect(posthog.init).toHaveBeenCalledWith( "phc_test", - expect.objectContaining({ before_send: maskUrlCodes }) + expect.objectContaining({ + before_send: [maskUrlCodes, dropChunkLoadErrors], + }) ); }); @@ -609,6 +613,51 @@ describe("usePostHog", () => { }); }); + describe("dropChunkLoadErrors", () => { + const CHUNK = + "Failed to fetch dynamically imported module: https://prunplanner.org/assets/chunks/HomepageView.abc123.js"; + const exception = (...values: string[]) => ({ + uuid: "u", + event: "$exception", + properties: { + $exception_list: values.map((value) => ({ + type: "TypeError", + value, + mechanism: { handled: false }, + })), + }, + }); + + it("drops an exception that is only a chunk-load error", async () => { + const { dropChunkLoadErrors } = await load(); + + expect(dropChunkLoadErrors(exception(CHUNK))).toBeNull(); + }); + + it("keeps other exceptions, mixed ones included", async () => { + const { dropChunkLoadErrors } = await load(); + const other = exception("Cannot read properties of undefined"); + const mixed = exception(CHUNK, "Cannot read properties of undefined"); + + expect(dropChunkLoadErrors(other)).toBe(other); + expect(dropChunkLoadErrors(mixed)).toBe(mixed); + }); + + it("keeps other events and exceptions without a list", async () => { + const { dropChunkLoadErrors } = await load(); + const pageview = { + uuid: "u", + event: "$pageview", + properties: { message: CHUNK }, + }; + const empty = { uuid: "u", event: "$exception", properties: {} }; + + expect(dropChunkLoadErrors(null)).toBeNull(); + expect(dropChunkLoadErrors(pageview)).toBe(pageview); + expect(dropChunkLoadErrors(empty)).toBe(empty); + }); + }); + // deny relies on posthog-js dropping requests while `__loaded` is false describe("real posthog-js", () => { async function captureWithRealPostHog() {