diff --git a/src/selfhost/sentry.ts b/src/selfhost/sentry.ts index 4edefda0ee..d19199bb53 100644 --- a/src/selfhost/sentry.ts +++ b/src/selfhost/sentry.ts @@ -87,6 +87,10 @@ export function captureReviewFailure( }); } +// The structured-log fields worth indexing as Sentry tags — the dimensions operators filter + group by. Only +// string|number values are tagged; everything else stays in the full "log" context. +const SENTRY_LOG_TAG_KEYS = ["repo", "repository", "installationId", "installation_id", "pull", "pullNumber", "pr", "project", "kind", "deliveryId"] as const; + /** Forward a structured console line to Sentry when it is an ERROR-level log. The engine logs operational * failures (orb_broker_unavailable, gate-check errors, relay drops, …) as JSON strings, often via console.error. * No-op when Sentry is off, the line isn't a JSON object string, or its level isn't error/fatal — routine logs @@ -104,16 +108,22 @@ export function forwardStructuredLogToSentry(line: unknown): void { const level = obj.level; if (level !== "error" && level !== "fatal") return; const severity = level === "fatal" ? "fatal" : "error"; - const title = - typeof obj.event === "string" - ? obj.event - : typeof obj.message === "string" - ? obj.message - : "error"; + const event = typeof obj.event === "string" ? obj.event : undefined; + // Lead the Sentry title with the real failure detail (message → error), not just the event slug, so an operator + // sees WHAT broke straight from the issue list instead of having to open the context blob. + const detail = typeof obj.message === "string" ? obj.message : typeof obj.error === "string" ? obj.error : undefined; + const title = event ? (detail ? `${event}: ${detail}` : event) : (detail ?? "error"); Sentry.withScope((scope) => { scope.setLevel(severity); scope.setContext("log", obj); - if (typeof obj.event === "string") scope.setTag("event", obj.event); + if (event) scope.setTag("event", event); + // Index the dimensions operators filter + group by, so issues are findable without digging into the context. + for (const key of SENTRY_LOG_TAG_KEYS) { + const value = obj[key]; + if (typeof value === "string" || typeof value === "number") scope.setTag(key, String(value)); + } + // Group recurrences of ONE failure into a single issue (by event, not the variable detail that's in the title). + if (event) scope.setFingerprint(["gittensory-log", event]); Sentry!.captureMessage(title, severity); }); } diff --git a/test/unit/selfhost-sentry.test.ts b/test/unit/selfhost-sentry.test.ts index 07316c1f27..fa283d1235 100644 --- a/test/unit/selfhost-sentry.test.ts +++ b/test/unit/selfhost-sentry.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock @sentry/node so the dynamic import inside initSentry() resolves to spies. Hoisted so vi.mock can see it. const mocks = vi.hoisted(() => { - const scope = { setContext: vi.fn(), setLevel: vi.fn(), setTag: vi.fn() }; + const scope = { setContext: vi.fn(), setLevel: vi.fn(), setTag: vi.fn(), setFingerprint: vi.fn() }; return { scope, init: vi.fn(), @@ -212,6 +212,26 @@ describe("forwardStructuredLogToSentry — central console.log → Sentry error ); }); + it("leads the title with the real error detail + indexes filterable tags + fingerprints by event (#observability)", async () => { + await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv); + forwardStructuredLogToSentry( + JSON.stringify({ + level: "error", + event: "orb_broker_unavailable", + error: "The operation was aborted due to timeout", + repo: "JSONbored/gittensory", + installationId: 143010787, + }), + ); + // The issue TITLE now carries the actual failure, not just the event slug — no hunting through the context blob. + expect(mocks.captureMessage).toHaveBeenCalledWith("orb_broker_unavailable: The operation was aborted due to timeout", "error"); + // The present log dimensions become filterable tags. + expect(mocks.scope.setTag).toHaveBeenCalledWith("repo", "JSONbored/gittensory"); + expect(mocks.scope.setTag).toHaveBeenCalledWith("installationId", "143010787"); + // Recurrences of one failure group into a single issue by event. + expect(mocks.scope.setFingerprint).toHaveBeenCalledWith(["gittensory-log", "orb_broker_unavailable"]); + }); + it("forwards a level:fatal log titled by message (no event ⇒ no tag)", async () => { await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv); forwardStructuredLogToSentry(