Skip to content
Merged
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
24 changes: 17 additions & 7 deletions src/selfhost/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
});
}
Expand Down
22 changes: 21 additions & 1 deletion test/unit/selfhost-sentry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(
Expand Down
Loading