From e830d99cee8f5bf6209086af63dcef6909b85142 Mon Sep 17 00:00:00 2001
From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com>
Date: Tue, 30 Jun 2026 03:35:43 -0700
Subject: [PATCH] feat(observability): add self-host Sentry monitors
---
.../routes/docs.self-hosting-operations.tsx | 30 +++
src/selfhost/monitored-work.ts | 75 ++++++++
src/selfhost/sentry.ts | 114 +++++++++++-
src/server.ts | 72 ++++---
test/unit/selfhost-monitored-work.test.ts | 176 ++++++++++++++++++
test/unit/selfhost-sentry.test.ts | 140 ++++++++++++++
6 files changed, 575 insertions(+), 32 deletions(-)
create mode 100644 src/selfhost/monitored-work.ts
create mode 100644 test/unit/selfhost-monitored-work.test.ts
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx
index a76c2a474d..0711d8cfca 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx
@@ -85,6 +85,36 @@ review_context_fetch_failed`}
+ Sentry cron monitors
+
+ When SENTRY_DSN is set, the self-host runtime emits Sentry monitor check-ins
+ for the recurring loops where silent stoppage matters most. Leaving SENTRY_DSN{" "}
+ unset keeps monitor reporting off.
+
+
+
+ A missed monitor means the process may still be alive but the recurring work is not checking
+ in on schedule. Pair the monitor with queue depth, dead-job counts, and the structured error
+ log for the same subsystem.
+
+
Routine checks
- Queue pending count is not growing without processing.
diff --git a/src/selfhost/monitored-work.ts b/src/selfhost/monitored-work.ts
new file mode 100644
index 0000000000..d8fa250cbe
--- /dev/null
+++ b/src/selfhost/monitored-work.ts
@@ -0,0 +1,75 @@
+import type { EnqueueWebhookResult } from "../github/webhook";
+import { withSentryMonitor } from "./sentry";
+
+export type OrbRelayEvent = {
+ deliveryId: string;
+ eventName: string;
+ rawBody: string;
+};
+
+export type OrbRelayDrainState = {
+ pendingAck: string[];
+};
+
+type OrbRelayEnv = {
+ ORB_ENROLLMENT_SECRET?: string | undefined;
+ ORB_BROKER_URL?: string | undefined;
+};
+
+export async function runScheduledLoopWithMonitor(
+ cron: string,
+ scheduled: () => T | Promise,
+): Promise {
+ return withSentryMonitor(
+ "scheduled-loop",
+ { jobType: "scheduled-loop", cron },
+ () => Promise.resolve(scheduled()),
+ );
+}
+
+export async function runOrbExportWithMonitor(
+ exportBatch: () => Promise,
+ log: (line: string) => void = console.log,
+): Promise {
+ await withSentryMonitor("orb-export", { jobType: "orb-export" }, async () => {
+ const exported = await exportBatch();
+ if (exported > 0)
+ log(JSON.stringify({ event: "selfhost_orb_export", exported }));
+ });
+}
+
+export async function drainOrbRelayWithMonitor(args: {
+ state: OrbRelayDrainState;
+ relayEnv: OrbRelayEnv;
+ env: Env;
+ drain: (env: OrbRelayEnv, ack: string[]) => Promise;
+ enqueue: (
+ env: Env,
+ deliveryId: string,
+ eventName: string,
+ rawBody: string,
+ ) => Promise;
+ log?: (line: string) => void;
+}): Promise {
+ await withSentryMonitor(
+ "orb-relay-drain",
+ { jobType: "orb-relay-drain", pendingAckCount: args.state.pendingAck.length },
+ async () => {
+ const events = await args.drain(args.relayEnv, args.state.pendingAck);
+ args.state.pendingAck = [];
+ for (const ev of events) {
+ const result = await args.enqueue(
+ args.env,
+ ev.deliveryId,
+ ev.eventName,
+ ev.rawBody,
+ );
+ if (result !== "enqueue_failed") args.state.pendingAck.push(ev.deliveryId);
+ }
+ if (events.length > 0)
+ (args.log ?? console.log)(
+ JSON.stringify({ event: "orb_relay_drained", count: events.length }),
+ );
+ },
+ );
+}
diff --git a/src/selfhost/sentry.ts b/src/selfhost/sentry.ts
index e28298090d..ecd5957e55 100644
--- a/src/selfhost/sentry.ts
+++ b/src/selfhost/sentry.ts
@@ -5,12 +5,15 @@
import { currentOtelTraceIds } from "./otel";
type SentryNs = typeof import("@sentry/node");
+type SentryMonitorConfig = NonNullable[1]>;
+export type SentryMonitorName = "scheduled-loop" | "orb-export" | "orb-relay-drain";
type SentryScope = {
setContext(name: string, context: Record): void;
setTag(key: string, value: string): void;
};
let Sentry: SentryNs | undefined;
let active = false;
+let sentryEnvironment = "production";
const SECRET_KEY =
/(token|secret|key|password|passwd|authorization|auth|dsn|cookie|bearer|credential|private)/i;
@@ -20,6 +23,72 @@ function nonBlank(value: string | undefined): string | undefined {
return trimmed ? trimmed : undefined;
}
+const SENTRY_MONITORS: Record = {
+ "scheduled-loop": {
+ slug: "scheduled-loop",
+ config: {
+ schedule: { type: "interval", value: 2, unit: "minute" },
+ checkinMargin: 3,
+ maxRuntime: 2,
+ failureIssueThreshold: 2,
+ recoveryThreshold: 1,
+ },
+ },
+ "orb-export": {
+ slug: "orb-export",
+ config: {
+ schedule: { type: "interval", value: 1, unit: "hour" },
+ checkinMargin: 10,
+ maxRuntime: 10,
+ failureIssueThreshold: 2,
+ recoveryThreshold: 1,
+ },
+ },
+ "orb-relay-drain": {
+ slug: "orb-relay-drain",
+ config: {
+ schedule: { type: "interval", value: 1, unit: "minute" },
+ checkinMargin: 2,
+ maxRuntime: 1,
+ failureIssueThreshold: 3,
+ recoveryThreshold: 1,
+ },
+ },
+};
+
+function slugPart(value: string | undefined): string {
+ const slug = nonBlank(value)
+ ?.toLowerCase()
+ .replace(/[^a-z0-9-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 48);
+ return slug || "production";
+}
+
+export function resolveSentryMonitorSlug(
+ name: SentryMonitorName,
+ environment = sentryEnvironment,
+): string {
+ return `gittensory-selfhost-${slugPart(environment)}-${SENTRY_MONITORS[name].slug}`;
+}
+
+function safeMonitorContext(
+ name: SentryMonitorName,
+ monitorSlug: string,
+ context: Record | undefined,
+): Record {
+ const safe: Record = { monitor: name, monitorSlug };
+ if (!context) return safe;
+ for (const [key, value] of Object.entries(context)) {
+ if (SECRET_KEY.test(key) || value === null || value === undefined) continue;
+ if (typeof value === "string")
+ safe[key] = value.length > 160 ? `${value.slice(0, 157)}...` : value;
+ else if (typeof value === "number" && Number.isFinite(value)) safe[key] = value;
+ else if (typeof value === "boolean") safe[key] = value;
+ }
+ return safe;
+}
+
function setOtelTraceScope(scope: SentryScope): void {
const trace = currentOtelTraceIds();
if (!trace) return;
@@ -65,9 +134,10 @@ export async function initSentry(env: NodeJS.ProcessEnv): Promise {
if (!env.SENTRY_DSN) return false;
Sentry = await import("@sentry/node");
const release = resolveSentryRelease(env);
+ sentryEnvironment = nonBlank(env.SENTRY_ENVIRONMENT) ?? "production";
Sentry.init({
dsn: env.SENTRY_DSN,
- environment: env.SENTRY_ENVIRONMENT ?? "production",
+ environment: sentryEnvironment,
...(release ? { release } : {}),
tracesSampleRate: Number(env.SENTRY_TRACES_SAMPLE_RATE ?? "0"),
serverName: env.PUBLIC_API_ORIGIN,
@@ -242,6 +312,47 @@ export function forwardStructuredLogToSentry(line: unknown, fromErrorSink = fals
});
}
+/** Wrap recurring self-host work with Sentry cron check-ins. No-op when Sentry is disabled. */
+export async function withSentryMonitor(
+ name: SentryMonitorName,
+ context: Record | undefined,
+ callback: () => Promise,
+): Promise {
+ if (!active || !Sentry) return callback();
+ const monitorSlug = resolveSentryMonitorSlug(name);
+ const checkInId = Sentry.captureCheckIn(
+ { monitorSlug, status: "in_progress" },
+ SENTRY_MONITORS[name].config,
+ );
+ const startedAt = Date.now();
+ try {
+ const result = await callback();
+ Sentry.captureCheckIn({
+ monitorSlug,
+ status: "ok",
+ checkInId,
+ duration: (Date.now() - startedAt) / 1000,
+ });
+ return result;
+ } catch (error) {
+ Sentry.captureCheckIn({
+ monitorSlug,
+ status: "error",
+ checkInId,
+ duration: (Date.now() - startedAt) / 1000,
+ });
+ Sentry.withScope((scope) => {
+ scope.setLevel("error");
+ setOtelTraceScope(scope);
+ scope.setContext("sentry_monitor", safeMonitorContext(name, monitorSlug, context));
+ scope.setTag("monitor", monitorSlug);
+ scope.setFingerprint(["gittensory-sentry-monitor", name]);
+ Sentry!.captureException(error instanceof Error ? error : new Error(String(error)));
+ });
+ throw error;
+ }
+}
+
/** Flush buffered events before exit. No-op when off. */
export async function flushSentry(timeoutMs = 2000): Promise {
if (!active || !Sentry) return;
@@ -252,6 +363,7 @@ export async function flushSentry(timeoutMs = 2000): Promise {
export function resetSentryForTest(): void {
Sentry = undefined;
active = false;
+ sentryEnvironment = "production";
}
interface StructuredLogConsole {
diff --git a/src/server.ts b/src/server.ts
index dfd1d645d0..2512655291 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -58,6 +58,11 @@ import {
initSentry,
installStructuredLogForwarding,
} from "./selfhost/sentry";
+import {
+ drainOrbRelayWithMonitor,
+ runOrbExportWithMonitor,
+ runScheduledLoopWithMonitor,
+} from "./selfhost/monitored-work";
import {
currentOtelTraceParent,
initOpenTelemetry,
@@ -741,13 +746,16 @@ async function main(): Promise {
// Cron — gittensory ticks ~every 2 minutes; drive the SAME scheduled handler.
const intervalMs = Number(process.env.CRON_INTERVAL_MS ?? 120_000);
+ /* v8 ignore start -- self-host entrypoint timers start a live server; monitor semantics are covered in selfhost tests. */
const cron = setInterval(() => {
const controller = {
scheduledTime: Date.now(),
cron: "*/2 * * * *",
noRetry: () => undefined,
} as unknown as ScheduledController;
- Promise.resolve(worker.scheduled(controller, env, ctx)).catch((error) =>
+ runScheduledLoopWithMonitor(controller.cron, () =>
+ worker.scheduled(controller, env, ctx),
+ ).catch((error) =>
console.error(
JSON.stringify({
level: "error",
@@ -757,28 +765,24 @@ async function main(): Promise {
),
);
}, intervalMs);
+ /* v8 ignore stop */
// Orb fleet-telemetry export — ALWAYS ON (the fleet-calibration contract of self-hosting). Self-gates
// inside exportOrbBatch: a no-op until the GitHub App is configured, or when ORB_AIR_GAP=true.
+ /* v8 ignore start -- self-host entrypoint timers start a live server; monitor semantics are covered in selfhost tests. */
const runOrbExport = () =>
- exportOrbBatch(backend.db)
- .then((n) => {
- if (n > 0)
- console.log(
- JSON.stringify({ event: "selfhost_orb_export", exported: n }),
- );
- })
- .catch((error) =>
- console.error(
- JSON.stringify({
- level: "error",
- event: "selfhost_orb_export_error",
- error: error instanceof Error ? error.message : "unknown error",
- }),
- ),
- );
+ runOrbExportWithMonitor(() => exportOrbBatch(backend.db)).catch((error) =>
+ console.error(
+ JSON.stringify({
+ level: "error",
+ event: "selfhost_orb_export_error",
+ error: error instanceof Error ? error.message : "unknown error",
+ }),
+ ),
+ );
void runOrbExport(); // flush any pending events at startup
setInterval(runOrbExport, 3_600_000); // then hourly
+ /* v8 ignore stop */
// Brokered self-host: register our relay target with the central Orb (best-effort, fire-and-forget). PUSH mode
// (default) registers a public relay URL the Orb POSTs to; PULL mode (ORB_RELAY_MODE=pull) registers no URL and
@@ -817,23 +821,29 @@ async function main(): Promise {
if (process.env.ORB_RELAY_MODE === "pull" && process.env.ORB_ENROLLMENT_SECRET) {
const { drainOrbRelay } = await import("./orb/broker-client");
const { enqueueWebhookByEnv } = await import("./github/webhook");
- let pendingAck: string[] = [];
+ const relayDrainState = { pendingAck: [] as string[] };
+ /* v8 ignore start -- pull-mode relay loop is a live self-host timer; monitor semantics are covered in selfhost tests. */
const drainRelay = async (): Promise => {
- const events = await drainOrbRelay(
- { ORB_ENROLLMENT_SECRET: process.env.ORB_ENROLLMENT_SECRET, ORB_BROKER_URL: process.env.ORB_BROKER_URL },
- pendingAck,
- );
- pendingAck = [];
- for (const ev of events) {
- const result = await enqueueWebhookByEnv(env, ev.deliveryId, ev.eventName, ev.rawBody);
- // Ack everything durably handled (queued / duplicate / invalid_json) so the Orb deletes it; retry only a
- // real enqueue failure on the next pull (don't ack → the Orb keeps it).
- if (result !== "enqueue_failed") pendingAck.push(ev.deliveryId);
- }
- if (events.length > 0) console.log(JSON.stringify({ event: "orb_relay_drained", count: events.length }));
+ await drainOrbRelayWithMonitor({
+ state: relayDrainState,
+ relayEnv: {
+ ORB_ENROLLMENT_SECRET: process.env.ORB_ENROLLMENT_SECRET,
+ ORB_BROKER_URL: process.env.ORB_BROKER_URL,
+ },
+ env,
+ drain: drainOrbRelay,
+ enqueue: enqueueWebhookByEnv,
+ });
};
void drainRelay();
- setInterval(() => void drainRelay().catch((error) => captureError(error, { kind: "orb_relay_drain" })), 15_000);
+ setInterval(
+ () =>
+ void drainRelay().catch((error) =>
+ captureError(error, { kind: "orb_relay_drain" }),
+ ),
+ 15_000,
+ );
+ /* v8 ignore stop */
}
// Graceful shutdown: stop accepting HTTP, let the queue finish, close the backend.
diff --git a/test/unit/selfhost-monitored-work.test.ts b/test/unit/selfhost-monitored-work.test.ts
new file mode 100644
index 0000000000..ef03e4d901
--- /dev/null
+++ b/test/unit/selfhost-monitored-work.test.ts
@@ -0,0 +1,176 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ withSentryMonitor: vi.fn(
+ async (_name: string, _context: Record, callback: () => Promise) =>
+ callback(),
+ ),
+}));
+
+vi.mock("../../src/selfhost/sentry", () => ({
+ withSentryMonitor: mocks.withSentryMonitor,
+}));
+
+import {
+ drainOrbRelayWithMonitor,
+ runOrbExportWithMonitor,
+ runScheduledLoopWithMonitor,
+ type OrbRelayDrainState,
+} from "../../src/selfhost/monitored-work";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("self-host monitored recurring work", () => {
+ it("runs the scheduled loop through the Sentry monitor with cron context", async () => {
+ const scheduled = vi.fn().mockResolvedValue("done");
+
+ await expect(runScheduledLoopWithMonitor("*/2 * * * *", scheduled)).resolves.toBe(
+ "done",
+ );
+
+ expect(mocks.withSentryMonitor).toHaveBeenCalledWith(
+ "scheduled-loop",
+ { jobType: "scheduled-loop", cron: "*/2 * * * *" },
+ expect.any(Function),
+ );
+ expect(scheduled).toHaveBeenCalledTimes(1);
+ });
+
+ it("logs Orb export counts only when the batch exported work", async () => {
+ const exportBatch = vi.fn().mockResolvedValueOnce(3).mockResolvedValueOnce(0);
+ const log = vi.fn();
+
+ await runOrbExportWithMonitor(exportBatch, log);
+ expect(mocks.withSentryMonitor).toHaveBeenLastCalledWith(
+ "orb-export",
+ { jobType: "orb-export" },
+ expect.any(Function),
+ );
+ expect(log).toHaveBeenCalledWith(
+ JSON.stringify({ event: "selfhost_orb_export", exported: 3 }),
+ );
+
+ log.mockClear();
+ await runOrbExportWithMonitor(exportBatch, log);
+ expect(log).not.toHaveBeenCalled();
+ });
+
+ it("uses console.log as the default export and relay drain logger", async () => {
+ const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
+ try {
+ await runOrbExportWithMonitor(async () => 1);
+ await drainOrbRelayWithMonitor({
+ state: { pendingAck: [] },
+ relayEnv: {},
+ env: {} as Env,
+ drain: vi.fn().mockResolvedValue([
+ { deliveryId: "queued-1", eventName: "pull_request", rawBody: "{}" },
+ ]),
+ enqueue: vi.fn().mockResolvedValue("queued"),
+ });
+
+ expect(consoleLog).toHaveBeenCalledWith(
+ JSON.stringify({ event: "selfhost_orb_export", exported: 1 }),
+ );
+ expect(consoleLog).toHaveBeenCalledWith(
+ JSON.stringify({ event: "orb_relay_drained", count: 1 }),
+ );
+ } finally {
+ consoleLog.mockRestore();
+ }
+ });
+
+ it("drains Orb relay events and retains acks only for durably handled deliveries", async () => {
+ const state: OrbRelayDrainState = { pendingAck: ["previous-delivery"] };
+ const relayEnv = {
+ ORB_ENROLLMENT_SECRET: "secret",
+ ORB_BROKER_URL: "https://orb.example",
+ };
+ const env = {} as Env;
+ const drain = vi.fn().mockResolvedValue([
+ { deliveryId: "queued-1", eventName: "pull_request", rawBody: "{}" },
+ { deliveryId: "failed-1", eventName: "push", rawBody: "{}" },
+ { deliveryId: "duplicate-1", eventName: "check_suite", rawBody: "{}" },
+ ]);
+ const enqueue = vi
+ .fn()
+ .mockResolvedValueOnce("queued")
+ .mockResolvedValueOnce("enqueue_failed")
+ .mockResolvedValueOnce("duplicate");
+ const log = vi.fn();
+
+ await drainOrbRelayWithMonitor({
+ state,
+ relayEnv,
+ env,
+ drain,
+ enqueue,
+ log,
+ });
+
+ expect(mocks.withSentryMonitor).toHaveBeenCalledWith(
+ "orb-relay-drain",
+ { jobType: "orb-relay-drain", pendingAckCount: 1 },
+ expect.any(Function),
+ );
+ expect(drain).toHaveBeenCalledWith(relayEnv, ["previous-delivery"]);
+ expect(enqueue).toHaveBeenNthCalledWith(
+ 1,
+ env,
+ "queued-1",
+ "pull_request",
+ "{}",
+ );
+ expect(enqueue).toHaveBeenNthCalledWith(2, env, "failed-1", "push", "{}");
+ expect(enqueue).toHaveBeenNthCalledWith(
+ 3,
+ env,
+ "duplicate-1",
+ "check_suite",
+ "{}",
+ );
+ expect(state.pendingAck).toEqual(["queued-1", "duplicate-1"]);
+ expect(log).toHaveBeenCalledWith(
+ JSON.stringify({ event: "orb_relay_drained", count: 3 }),
+ );
+ });
+
+ it("clears previous Orb relay acks and stays quiet when the broker has no events", async () => {
+ const state: OrbRelayDrainState = { pendingAck: ["previous-delivery"] };
+ const drain = vi.fn().mockResolvedValue([]);
+ const enqueue = vi.fn();
+ const log = vi.fn();
+
+ await drainOrbRelayWithMonitor({
+ state,
+ relayEnv: {},
+ env: {} as Env,
+ drain,
+ enqueue,
+ log,
+ });
+
+ expect(state.pendingAck).toEqual([]);
+ expect(enqueue).not.toHaveBeenCalled();
+ expect(log).not.toHaveBeenCalled();
+ });
+
+ it("preserves pending Orb relay acks when the broker drain throws before delivery state is known", async () => {
+ const state: OrbRelayDrainState = { pendingAck: ["previous-delivery"] };
+ const drain = vi.fn().mockRejectedValue(new Error("broker down"));
+
+ await expect(
+ drainOrbRelayWithMonitor({
+ state,
+ relayEnv: {},
+ env: {} as Env,
+ drain,
+ enqueue: vi.fn(),
+ }),
+ ).rejects.toThrow("broker down");
+
+ expect(state.pendingAck).toEqual(["previous-delivery"]);
+ });
+});
diff --git a/test/unit/selfhost-sentry.test.ts b/test/unit/selfhost-sentry.test.ts
index 1c1009ba95..a027241de5 100644
--- a/test/unit/selfhost-sentry.test.ts
+++ b/test/unit/selfhost-sentry.test.ts
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => {
withScope: vi.fn((cb: (s: typeof scope) => void) => cb(scope)),
captureException: vi.fn(),
captureMessage: vi.fn(),
+ captureCheckIn: vi.fn((checkIn: { checkInId?: string }) => checkIn.checkInId ?? "check-in-id"),
flush: vi.fn().mockResolvedValue(true),
};
});
@@ -20,6 +21,7 @@ vi.mock("@sentry/node", () => ({
withScope: mocks.withScope,
captureException: mocks.captureException,
captureMessage: mocks.captureMessage,
+ captureCheckIn: mocks.captureCheckIn,
flush: mocks.flush,
}));
vi.mock("../../src/selfhost/otel", () => ({
@@ -34,8 +36,10 @@ import {
forwardStructuredLogToSentry,
installStructuredLogForwarding,
resolveSentryRelease,
+ resolveSentryMonitorSlug,
scrubEvent,
resetSentryForTest,
+ withSentryMonitor,
} from "../../src/selfhost/sentry";
beforeEach(() => {
@@ -87,9 +91,17 @@ describe("disabled when SENTRY_DSN is unset (modular opt-out → complete no-op)
expect(await initSentry({} as unknown as NodeJS.ProcessEnv)).toBe(false);
captureError(new Error("x"), { a: 1 });
captureReviewFailure(new Error("y"), { repo: "o/r" });
+ await expect(
+ withSentryMonitor(
+ "scheduled-loop",
+ { jobType: "scheduled-loop" },
+ async () => "ok",
+ ),
+ ).resolves.toBe("ok");
await flushSentry();
expect(mocks.init).not.toHaveBeenCalled();
expect(mocks.captureException).not.toHaveBeenCalled();
+ expect(mocks.captureCheckIn).not.toHaveBeenCalled();
expect(mocks.flush).not.toHaveBeenCalled();
});
});
@@ -239,6 +251,134 @@ describe("enabled when SENTRY_DSN is set", () => {
mocks.flush.mockRejectedValueOnce(new Error("network"));
await expect(flushSentry()).resolves.toBeUndefined();
});
+
+ it("builds stable environment-aware monitor slugs", () => {
+ expect(resolveSentryMonitorSlug("scheduled-loop", "Prod East/1")).toBe(
+ "gittensory-selfhost-prod-east-1-scheduled-loop",
+ );
+ expect(resolveSentryMonitorSlug("orb-export", " !!! ")).toBe(
+ "gittensory-selfhost-production-orb-export",
+ );
+ expect(resolveSentryMonitorSlug("orb-relay-drain", "x".repeat(60))).toBe(
+ `gittensory-selfhost-${"x".repeat(48)}-orb-relay-drain`,
+ );
+ });
+
+ it("records successful Sentry cron monitor check-ins with the configured schedule", async () => {
+ await initSentry({
+ SENTRY_DSN: "d",
+ SENTRY_ENVIRONMENT: "Self Host",
+ } as unknown as NodeJS.ProcessEnv);
+
+ await expect(
+ withSentryMonitor(
+ "scheduled-loop",
+ { jobType: "scheduled-loop" },
+ async () => "ok",
+ ),
+ ).resolves.toBe("ok");
+
+ expect(mocks.captureCheckIn).toHaveBeenNthCalledWith(
+ 1,
+ { monitorSlug: "gittensory-selfhost-self-host-scheduled-loop", status: "in_progress" },
+ expect.objectContaining({
+ schedule: { type: "interval", value: 2, unit: "minute" },
+ checkinMargin: 3,
+ maxRuntime: 2,
+ failureIssueThreshold: 2,
+ recoveryThreshold: 1,
+ }),
+ );
+ expect(mocks.captureCheckIn).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ monitorSlug: "gittensory-selfhost-self-host-scheduled-loop",
+ status: "ok",
+ checkInId: "check-in-id",
+ duration: expect.any(Number),
+ }),
+ );
+ expect(mocks.captureException).not.toHaveBeenCalled();
+ });
+
+ it("records failed Sentry cron monitor check-ins with sanitized context", async () => {
+ await initSentry({
+ SENTRY_DSN: "d",
+ SENTRY_ENVIRONMENT: "prod",
+ } as unknown as NodeJS.ProcessEnv);
+ const longText = "x".repeat(200);
+
+ await expect(
+ withSentryMonitor(
+ "orb-export",
+ {
+ jobType: "orb-export",
+ repo: "JSONbored/gittensory",
+ exported: 7,
+ dryRun: false,
+ token: "secret",
+ privateKey: "key",
+ badNumber: Number.NaN,
+ nested: { ignored: true },
+ empty: null,
+ missing: undefined,
+ longText,
+ },
+ async () => {
+ throw new Error("export failed");
+ },
+ ),
+ ).rejects.toThrow("export failed");
+
+ expect(mocks.captureCheckIn).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ monitorSlug: "gittensory-selfhost-prod-orb-export",
+ status: "error",
+ checkInId: "check-in-id",
+ duration: expect.any(Number),
+ }),
+ );
+ expect(mocks.scope.setLevel).toHaveBeenCalledWith("error");
+ expect(mocks.scope.setTag).toHaveBeenCalledWith(
+ "monitor",
+ "gittensory-selfhost-prod-orb-export",
+ );
+ expect(mocks.scope.setFingerprint).toHaveBeenCalledWith([
+ "gittensory-sentry-monitor",
+ "orb-export",
+ ]);
+ expect(mocks.scope.setContext).toHaveBeenCalledWith("sentry_monitor", {
+ monitor: "orb-export",
+ monitorSlug: "gittensory-selfhost-prod-orb-export",
+ jobType: "orb-export",
+ repo: "JSONbored/gittensory",
+ exported: 7,
+ dryRun: false,
+ longText: `${"x".repeat(157)}...`,
+ });
+ expect(JSON.stringify(mocks.scope.setContext.mock.calls)).not.toContain("secret");
+ expect(JSON.stringify(mocks.scope.setContext.mock.calls)).not.toContain("key");
+ expect(mocks.captureException).toHaveBeenCalledWith(expect.any(Error));
+ });
+
+ it("records monitor failures without context and normalizes non-Error throws", async () => {
+ await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv);
+
+ await expect(
+ withSentryMonitor("orb-relay-drain", undefined, async () => {
+ throw "relay failed";
+ }),
+ ).rejects.toBe("relay failed");
+
+ expect(mocks.scope.setContext).toHaveBeenCalledWith("sentry_monitor", {
+ monitor: "orb-relay-drain",
+ monitorSlug: "gittensory-selfhost-production-orb-relay-drain",
+ });
+ expect((mocks.captureException.mock.calls.at(-1)?.[0] as Error).message).toBe(
+ "relay failed",
+ );
+ });
});
describe("forwardStructuredLogToSentry — central console.log → Sentry error forwarding (#1468)", () => {