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() {