Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions services/platform/app/lib/sentry/convex-console-events.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
100 changes: 100 additions & 0 deletions services/platform/app/lib/sentry/convex-console-events.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions services/platform/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'),
});
}
Expand Down
Loading