diff --git a/.github/workflows/dev-deploy-vercel-dashboard.yml b/.github/workflows/dev-deploy-vercel-dashboard.yml index af0985b3..201e1d1e 100644 --- a/.github/workflows/dev-deploy-vercel-dashboard.yml +++ b/.github/workflows/dev-deploy-vercel-dashboard.yml @@ -59,6 +59,19 @@ jobs: run: vercel build --target=staging --token=${{ secrets.VERCEL_TOKEN }} working-directory: echo/frontend + - name: Upload source maps to PostHog + continue-on-error: true + working-directory: echo/frontend + env: + POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }} + POSTHOG_CLI_PROJECT_ID: "197841" + run: | + curl --proto '=https' --tlsv1.2 -LsSf https://download.posthog.com/cli | sh + "$HOME/.posthog/posthog-cli" --host https://eu.posthog.com sourcemap process \ + --directory .vercel/output/static \ + --release-version "$GITHUB_SHA" \ + --delete-after + - name: Deploy Project Artifacts to Vercel run: vercel deploy --prebuilt --target=staging --token=${{ secrets.VERCEL_TOKEN }} working-directory: echo/frontend \ No newline at end of file diff --git a/.github/workflows/dev-deploy-vercel-portal.yml b/.github/workflows/dev-deploy-vercel-portal.yml index eaea8c3c..c525ca22 100644 --- a/.github/workflows/dev-deploy-vercel-portal.yml +++ b/.github/workflows/dev-deploy-vercel-portal.yml @@ -59,6 +59,19 @@ jobs: run: vercel build --target=staging --token=${{ secrets.VERCEL_TOKEN }} working-directory: echo/frontend + - name: Upload source maps to PostHog + continue-on-error: true + working-directory: echo/frontend + env: + POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }} + POSTHOG_CLI_PROJECT_ID: "197841" + run: | + curl --proto '=https' --tlsv1.2 -LsSf https://download.posthog.com/cli | sh + "$HOME/.posthog/posthog-cli" --host https://eu.posthog.com sourcemap process \ + --directory .vercel/output/static \ + --release-version "$GITHUB_SHA" \ + --delete-after + - name: Deploy Project Artifacts to Vercel run: vercel deploy --prebuilt --target=staging --token=${{ secrets.VERCEL_TOKEN }} working-directory: echo/frontend \ No newline at end of file diff --git a/.github/workflows/prod-deploy-vercel-dashboard.yml b/.github/workflows/prod-deploy-vercel-dashboard.yml index c4947f5e..abaf9a8d 100644 --- a/.github/workflows/prod-deploy-vercel-dashboard.yml +++ b/.github/workflows/prod-deploy-vercel-dashboard.yml @@ -61,6 +61,19 @@ jobs: run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} working-directory: echo/frontend + - name: Upload source maps to PostHog + continue-on-error: true + working-directory: echo/frontend + env: + POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }} + POSTHOG_CLI_PROJECT_ID: "160282" + run: | + curl --proto '=https' --tlsv1.2 -LsSf https://download.posthog.com/cli | sh + "$HOME/.posthog/posthog-cli" --host https://eu.posthog.com sourcemap process \ + --directory .vercel/output/static \ + --release-version "$GITHUB_SHA" \ + --delete-after + - name: Deploy Project Artifacts to Vercel run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} working-directory: echo/frontend \ No newline at end of file diff --git a/.github/workflows/prod-deploy-vercel-portal.yml b/.github/workflows/prod-deploy-vercel-portal.yml index 0666db2e..a4f290c1 100644 --- a/.github/workflows/prod-deploy-vercel-portal.yml +++ b/.github/workflows/prod-deploy-vercel-portal.yml @@ -59,6 +59,19 @@ jobs: run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} working-directory: echo/frontend + - name: Upload source maps to PostHog + continue-on-error: true + working-directory: echo/frontend + env: + POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }} + POSTHOG_CLI_PROJECT_ID: "160282" + run: | + curl --proto '=https' --tlsv1.2 -LsSf https://download.posthog.com/cli | sh + "$HOME/.posthog/posthog-cli" --host https://eu.posthog.com sourcemap process \ + --directory .vercel/output/static \ + --release-version "$GITHUB_SHA" \ + --delete-after + - name: Deploy Project Artifacts to Vercel run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} working-directory: echo/frontend \ No newline at end of file diff --git a/echo/frontend/src/lib/errorTracking.test.ts b/echo/frontend/src/lib/errorTracking.test.ts new file mode 100644 index 00000000..de80b221 --- /dev/null +++ b/echo/frontend/src/lib/errorTracking.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +// @vitest-environment-options { "url": "https://portal.dembrane.com/nl-NL/abc123/start" } + +import type { CaptureResult } from "posthog-js"; +import { expect, it } from "vitest"; +import { dropInjectedScriptExceptions } from "./errorTracking"; + +const PAGE_URL = "https://portal.dembrane.com/nl-NL/abc123/start"; + +const exceptionEvent = (frames: Array<{ filename?: string }>): CaptureResult => + ({ + event: "$exception", + properties: { + $exception_list: [{ stacktrace: { frames } }], + }, + }) as unknown as CaptureResult; + +it("drops an exception whose only frame is the document URL", () => { + const event = exceptionEvent([{ filename: PAGE_URL }]); + expect(dropInjectedScriptExceptions(event)).toBeNull(); +}); + +it("keeps an exception with a frame that points at a script asset", () => { + const event = exceptionEvent([ + { filename: "https://portal.dembrane.com/assets/index-a1b2c3.js" }, + ]); + expect(dropInjectedScriptExceptions(event)).toBe(event); +}); + +it("keeps an exception that mixes a document frame with an asset frame", () => { + const event = exceptionEvent([ + { filename: PAGE_URL }, + { filename: "https://portal.dembrane.com/assets/index-a1b2c3.js" }, + ]); + expect(dropInjectedScriptExceptions(event)).toBe(event); +}); + +it("keeps an exception that has no stack frames", () => { + const event = exceptionEvent([]); + expect(dropInjectedScriptExceptions(event)).toBe(event); +}); + +it("ignores non-exception events", () => { + const event = { event: "$pageview", properties: {} } as CaptureResult; + expect(dropInjectedScriptExceptions(event)).toBe(event); +}); + +it("passes a null event through untouched", () => { + expect(dropInjectedScriptExceptions(null)).toBeNull(); +}); diff --git a/echo/frontend/src/lib/errorTracking.ts b/echo/frontend/src/lib/errorTracking.ts new file mode 100644 index 00000000..ea29124c --- /dev/null +++ b/echo/frontend/src/lib/errorTracking.ts @@ -0,0 +1,56 @@ +// PostHog exception autocapture picks up errors thrown by scripts that in-app +// mobile browsers and extensions inject into the page (the "Script error." and +// window.__firefox__ family). Their only stack frame points at the HTML +// document itself, never at one of our bundled /assets/*.js chunks, so they are +// noise we can drop before it reaches error tracking. Real portal crashes keep +// frames that resolve to script assets and pass through untouched. + +import type { CaptureResult } from "posthog-js"; + +interface StackFrame { + filename?: string; +} + +interface CapturedException { + stacktrace?: { + frames?: StackFrame[]; + }; +} + +// A frame belongs to the HTML document (not a script asset) when its filename +// resolves to the same origin and path as the current page. +const isDocumentFrame = (filename?: string): boolean => { + if (!filename) return false; + try { + const frameUrl = new URL(filename, window.location.href); + return ( + frameUrl.origin === window.location.origin && + frameUrl.pathname === window.location.pathname + ); + } catch { + return false; + } +}; + +// Drops an $exception event whose every frame points at the document URL. +// Anything else, including events with no stack frames, passes through. +export const dropInjectedScriptExceptions = ( + event: CaptureResult | null, +): CaptureResult | null => { + if (!event || event.event !== "$exception") return event; + + const exceptions = event.properties?.$exception_list as + | CapturedException[] + | undefined; + if (!Array.isArray(exceptions)) return event; + + const frames = exceptions.flatMap( + (exception) => exception?.stacktrace?.frames ?? [], + ); + if (frames.length === 0) return event; + + const everyFrameIsDocument = frames.every((frame) => + isDocumentFrame(frame?.filename), + ); + return everyFrameIsDocument ? null : event; +}; diff --git a/echo/frontend/src/main.tsx b/echo/frontend/src/main.tsx index ea07d8c2..0313552d 100644 --- a/echo/frontend/src/main.tsx +++ b/echo/frontend/src/main.tsx @@ -13,9 +13,14 @@ import { USE_PARTICIPANT_ROUTER, } from "./config"; import { recoverFromChunkFailure } from "./lib/appVersion"; +import { dropInjectedScriptExceptions } from "./lib/errorTracking"; posthog.init(POSTHOG_TOKEN, { api_host: POSTHOG_HOST, + // Drop exceptions thrown by scripts that in-app mobile browsers and + // extensions inject into the page. Their only frame points at the document + // URL, so they arrive as unattributable noise next to real crashes. + before_send: dropInjectedScriptExceptions, // Error tracking: autocapture unhandled errors and promise rejections. // React render errors are reported separately via ErrorBoundary. capture_exceptions: { diff --git a/echo/frontend/vite.config.ts b/echo/frontend/vite.config.ts index be8c14d9..7013e47a 100644 --- a/echo/frontend/vite.config.ts +++ b/echo/frontend/vite.config.ts @@ -158,6 +158,12 @@ export default defineConfig(({ mode }) => { }, }, }, + // "hidden" emits .map files without a sourceMappingURL comment, so the + // maps are never referenced from the served bundles. CI injects a chunk + // id and uploads them to PostHog error tracking, then deletes them (see + // the Vercel deploy workflows), so real portal crashes symbolicate + // instead of arriving minified. + sourcemap: "hidden", }, define: { __APP_BUILD_ID__: JSON.stringify(buildId),