From 0603a3c07dab2d0c6b918588ea7b8eeac00486b3 Mon Sep 17 00:00:00 2001 From: larryro <5787117+larryro@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:04:20 +0000 Subject: [PATCH] fix(platform): group browser Sentry events for repeated Convex errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit convex/react logs every failed call with a per-call [Request ID: …] in the message, and captureConsoleIntegration promotes each line to an event with no beforeSend/fingerprint configured — so a recurring failure minted one GlitchTip issue PER EVENT (100+ single-event issues during the deleted-org incident, a third of the demo project's total) and real regressions drowned. beforeSend now strips the Request ID out of Convex client lines and fingerprints on the stripped text; the id survives as a searchable convex.request_id tag and the raw console args in extra.arguments. Console copies of outcomes the client handles terminally (ORG_NOT_FOUND / ORG_FORBIDDEN bounce, UNAUTHENTICATED session-rotation retry) are dropped — the server-side report exists; errors that reached a boundary are kept and grouped the same way. --- .../lib/sentry/convex-console-events.test.ts | 109 ++++++++++++++++++ .../app/lib/sentry/convex-console-events.ts | 100 ++++++++++++++++ services/platform/app/router.tsx | 5 + 3 files changed, 214 insertions(+) create mode 100644 services/platform/app/lib/sentry/convex-console-events.test.ts create mode 100644 services/platform/app/lib/sentry/convex-console-events.ts diff --git a/services/platform/app/lib/sentry/convex-console-events.test.ts b/services/platform/app/lib/sentry/convex-console-events.test.ts new file mode 100644 index 0000000000..6de4dd063a --- /dev/null +++ b/services/platform/app/lib/sentry/convex-console-events.test.ts @@ -0,0 +1,109 @@ +import type { ErrorEvent } from '@sentry/tanstackstart-react'; +import { describe, expect, it } from 'vitest'; + +import { + convexConsoleBeforeSend, + normalizeConvexConsoleMessage, +} from './convex-console-events'; + +// #3020 regression: convex/react embeds a unique `[Request ID: …]` in every +// failure it logs, so two occurrences of the SAME failure never grouped — +// one GlitchTip issue per event. The beforeSend must make two such events +// carry one identity, keep the id findable, and drop only the console copies +// of outcomes the app already handles. + +const LIST_AGENTS_A = + '[CONVEX A(agents/actions:listAgents)] [Request ID: 5dabf348f1164725] Server Error'; +const LIST_AGENTS_B = + '[CONVEX A(agents/actions:listAgents)] [Request ID: 91c0aa10777d4e02] Server Error'; + +// `ErrorEvent` distinguishes itself from transactions by a required +// `type: undefined` — spell it out so these literals typecheck. +function consoleEvent(message: string): ErrorEvent { + return { type: undefined, logger: 'console', message }; +} + +function exceptionEvent(value: string): ErrorEvent { + return { + type: undefined, + exception: { values: [{ type: 'Error', value }] }, + }; +} + +describe('normalizeConvexConsoleMessage', () => { + it('strips the request id and hands it back separately', () => { + expect(normalizeConvexConsoleMessage(LIST_AGENTS_A)).toEqual({ + message: '[CONVEX A(agents/actions:listAgents)] Server Error', + requestId: '5dabf348f1164725', + }); + }); + + it('leaves non-convex text alone', () => { + expect(normalizeConvexConsoleMessage('ResizeObserver loop limit')).toBe( + null, + ); + }); + + it('accepts a line without a request id', () => { + expect( + normalizeConvexConsoleMessage('[CONVEX Q(members/queries:list)] boom'), + ).toEqual({ + message: '[CONVEX Q(members/queries:list)] boom', + requestId: undefined, + }); + }); +}); + +describe('convexConsoleBeforeSend', () => { + it('gives two occurrences of one failure the same identity', () => { + const first = convexConsoleBeforeSend(consoleEvent(LIST_AGENTS_A)); + const second = convexConsoleBeforeSend(consoleEvent(LIST_AGENTS_B)); + + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + if (!first || !second) return; + // Same fingerprint AND same message — grouped even where custom + // fingerprints are not honoured. + expect(first.fingerprint).toEqual(second.fingerprint); + expect(first.message).toBe(second.message); + expect(first.message).not.toContain('Request ID'); + // The per-call id stays findable as a tag. + expect(first.tags).toEqual({ 'convex.request_id': '5dabf348f1164725' }); + expect(second.tags).toEqual({ 'convex.request_id': '91c0aa10777d4e02' }); + }); + + it.each(['ORG_NOT_FOUND', 'ORG_FORBIDDEN', 'UNAUTHENTICATED'])( + 'drops the console copy of a client-handled %s failure', + (code) => { + const event = consoleEvent( + `[CONVEX A(branding/file_actions:readBranding)] [Request ID: ab12] Server Error: Uncaught ConvexError: {"code":"${code}","message":"…"}`, + ); + expect(convexConsoleBeforeSend(event)).toBeNull(); + }, + ); + + it('keeps a boundary exception even for a handled code, but groups it', () => { + const event = exceptionEvent( + '[CONVEX Q(members/queries:getCurrentMemberContext)] [Request ID: cd34] Server Error: Uncaught ConvexError: {"code":"ORG_NOT_FOUND","message":"…"}', + ); + const sent = convexConsoleBeforeSend(event); + + expect(sent).not.toBeNull(); + if (!sent) return; + expect(sent.exception?.values?.[0]?.value).not.toContain('Request ID'); + expect(sent.fingerprint).toHaveLength(1); + expect(sent.tags).toEqual({ 'convex.request_id': 'cd34' }); + }); + + it('passes foreign events through untouched', () => { + const message = consoleEvent('ResizeObserver loop limit exceeded'); + const exception = exceptionEvent('Cannot read properties of undefined'); + + expect(convexConsoleBeforeSend(message)).toBe(message); + expect(message.fingerprint).toBeUndefined(); + expect(message.message).toBe('ResizeObserver loop limit exceeded'); + + expect(convexConsoleBeforeSend(exception)).toBe(exception); + expect(exception.fingerprint).toBeUndefined(); + }); +}); diff --git a/services/platform/app/lib/sentry/convex-console-events.ts b/services/platform/app/lib/sentry/convex-console-events.ts new file mode 100644 index 0000000000..cba3f6c2f3 --- /dev/null +++ b/services/platform/app/lib/sentry/convex-console-events.ts @@ -0,0 +1,100 @@ +/** + * Grouping normalization for Convex client errors reaching Sentry (#3020). + * + * convex/react logs every failed call via + * `console.error('[CONVEX A(module:fn)] [Request ID: abc123] Server Error …')`, + * and `captureConsoleIntegration` promotes each such line to an event. The + * embedded Request ID is unique per call, so message-based grouping never + * matches — a recurring failure mints a brand-new GlitchTip issue per EVENT + * (100+ single-event issues during the #3019 incident, enough to bury a real + * regression) instead of one issue with N events. + * + * `beforeSend` therefore strips the Request ID out of the message and + * fingerprints on the stripped text. Nothing is lost: the id survives as a + * searchable `convex.request_id` tag, and the integration already preserves + * the raw console arguments in `extra.arguments`. + * + * Console-captured lines whose payload carries a code the client handles + * terminally (org gone / not a member / session rotation) are dropped + * outright: the recovery path is the feature, and the server-side report of + * the same failure already exists. An error that actually reached a boundary + * (an exception event) is real user impact and is kept — just grouped. + */ + +import type { ErrorEvent } from '@sentry/tanstackstart-react'; + +/** The `[CONVEX Q(module:fn)]` / `M` / `A` prefix convex/react logs. */ +const CONVEX_CONSOLE_LINE = /^\[CONVEX [A-Z]+\([^)]*\)\]/; +const REQUEST_ID = /\s*\[Request ID: ([^\]]*)\]/; + +/** + * Codes the app already handles end-to-end: `ORG_NOT_FOUND` / `ORG_FORBIDDEN` + * bounce the tab to the org list (`dashboard/$id.tsx`), `UNAUTHENTICATED` is + * the session-rotation window the layout boundary deliberately retries + * (`layout-error-boundary.tsx`, #2013). Only applied to console-captured + * lines — never to exceptions that reached a boundary. + */ +const CLIENT_HANDLED_CODES = /ORG_NOT_FOUND|ORG_FORBIDDEN|UNAUTHENTICATED/; + +interface NormalizedConvexMessage { + message: string; + requestId: string | undefined; +} + +/** + * Strip the per-call Request ID out of a Convex client error line. Returns + * `null` when the text is not one, so callers leave foreign events alone. + */ +export function normalizeConvexConsoleMessage( + message: string, +): NormalizedConvexMessage | null { + if (!CONVEX_CONSOLE_LINE.test(message)) return null; + const requestId = REQUEST_ID.exec(message)?.[1]; + return { message: message.replace(REQUEST_ID, ''), requestId }; +} + +function applyGrouping( + event: ErrorEvent, + normalized: NormalizedConvexMessage, +): void { + // GlitchTip groups on the fingerprint when present; capped so an embedded + // payload dump cannot make every fingerprint unique all over again. + event.fingerprint = [normalized.message.slice(0, 200)]; + if (normalized.requestId) { + event.tags = { ...event.tags, 'convex.request_id': normalized.requestId }; + } +} + +/** + * `Sentry.init#beforeSend`: group Convex client failures by what failed, not + * by which request happened to fail first. + */ +export function convexConsoleBeforeSend(event: ErrorEvent): ErrorEvent | null { + // Console-captured failures: `captureConsoleIntegration` produces message + // events and stamps them `logger: 'console'`. + if (typeof event.message === 'string') { + const normalized = normalizeConvexConsoleMessage(event.message); + if (normalized) { + if ( + event.logger === 'console' && + CLIENT_HANDLED_CODES.test(normalized.message) + ) { + return null; + } + event.message = normalized.message; + applyGrouping(event, normalized); + return event; + } + } + + // Errors thrown into a boundary carry the same text on the exception value. + for (const exception of event.exception?.values ?? []) { + if (typeof exception.value !== 'string') continue; + const normalized = normalizeConvexConsoleMessage(exception.value); + if (!normalized) continue; + exception.value = normalized.message; + applyGrouping(event, normalized); + break; + } + return event; +} diff --git a/services/platform/app/router.tsx b/services/platform/app/router.tsx index ae3bbe5ca6..696749d7e2 100644 --- a/services/platform/app/router.tsx +++ b/services/platform/app/router.tsx @@ -7,6 +7,7 @@ import { GlobalErrorDisplay } from '@/app/components/error-boundaries/displays/g import { RouteNotFound } from '@/app/components/layout/route-not-found'; import { warmSession } from '@/app/lib/auth/session-query'; import { markColdLoad } from '@/app/lib/perf/cold-load-trace'; +import { convexConsoleBeforeSend } from '@/app/lib/sentry/convex-console-events'; import { getEnv } from '@/lib/env'; import { routeTree } from './routeTree.gen'; @@ -89,6 +90,10 @@ if (sentryDsn) { // Kept to `error` only (not `warn`) to bound event volume. Sentry.captureConsoleIntegration({ levels: ['error'] }), ], + // convex/react embeds a unique `[Request ID: …]` in every failure it + // logs, so without normalization each recurrence of the SAME failure + // becomes a brand-new issue and buries real regressions (#3020). + beforeSend: convexConsoleBeforeSend, tracesSampleRate: getEnv('SENTRY_TRACES_SAMPLE_RATE'), }); }