Skip to content

Commit 15e308c

Browse files
feat(selfhost): add a live dead-letter-rate gauge sampled from the audit ledger (#3244)
1 parent 125354c commit 15e308c

4 files changed

Lines changed: 62 additions & 0 deletions

File tree

src/selfhost/dlq-recent.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { countRecentDeadLetters } from "../db/repositories";
2+
3+
// Trailing window for the "is the DLQ dead-lettering right now?" gauge (#2083). Operators alert on the RATE of
4+
// recent DLQ-consumer drops, which the cumulative `gittensory_dlq_dead_lettered_total` counter and the point-in-time
5+
// `gittensory_queue_dead` depth gauge can't express on their own.
6+
export const DLQ_RECENT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
7+
8+
/** ISO-8601 timestamp `windowMs` before `now` (default: current time). Pure given `now`; the injectable clock keeps
9+
* the window math deterministic in tests, and matches the ISO-compare convention used by the queue reliability work. */
10+
export function isoNowMinus(windowMs: number, now: number = Date.now()): string {
11+
return new Date(now - windowMs).toISOString();
12+
}
13+
14+
/** Scrape-time sample of DLQ dead-letters within the trailing window. Swallows a query error so a transient DB
15+
* hiccup degrades the sample to 0 rather than rejecting and breaking the whole `/metrics` scrape. */
16+
export async function sampleRecentDeadLetters(env: Env, now: number = Date.now()): Promise<number> {
17+
try {
18+
return await countRecentDeadLetters(env, isoNowMinus(DLQ_RECENT_WINDOW_MS, now));
19+
} catch {
20+
return 0;
21+
}
22+
}

src/selfhost/metrics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const histograms = new Map<string, HistogramState>();
3636
const DEFAULT_METRIC_META: readonly (readonly [string, MetricMeta])[] = [
3737
["gittensory_queue_pending", { help: "Current in-process queue depth.", type: "gauge" }],
3838
["gittensory_queue_dead", { help: "Current in-process dead queue depth.", type: "gauge" }],
39+
["gittensory_dlq_dead_lettered_recent", { help: "DLQ messages dead-lettered within the recent trailing window, sampled at scrape.", type: "gauge" }],
3940
["gittensory_queue_processing", { help: "Jobs currently claimed and mid-flight.", type: "gauge" }],
4041
["gittensory_queue_runnable_now", { help: "Pending jobs, any priority, currently due (run_after<=now).", type: "gauge" }],
4142
["gittensory_queue_live_pending", { help: "Current live-work queue depth.", type: "gauge" }],

src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ import {
9898
setLocalReviewContextReader,
9999
} from "./signals/focus-manifest-loader";
100100
import { probeReesSecretAtStartup } from "./review/enrichment-wire";
101+
import { sampleRecentDeadLetters } from "./selfhost/dlq-recent";
101102
import type { JobMessage } from "./types";
102103

103104
/** Resolve `<NAME>_FILE` env vars (Docker secrets / multi-line keys) into `<NAME>` at startup. */
@@ -621,6 +622,7 @@ async function main(): Promise<void> {
621622

622623
gauge("gittensory_queue_pending", () => backend.queue.size());
623624
gauge("gittensory_queue_dead", () => backend.queue.deadCount());
625+
gauge("gittensory_dlq_dead_lettered_recent", () => sampleRecentDeadLetters(env));
624626
gauge("gittensory_queue_processing", () => backend.queue.processingCount());
625627
const durableJobMetric = async (name: string): Promise<number> =>
626628
Number((await backend.queue.stats())[name] ?? 0);

test/unit/dlq-recent.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import * as repositories from "../../src/db/repositories";
3+
import { DLQ_RECENT_WINDOW_MS, isoNowMinus, sampleRecentDeadLetters } from "../../src/selfhost/dlq-recent";
4+
5+
describe("dlq-recent gauge helpers (#2083)", () => {
6+
afterEach(() => vi.restoreAllMocks());
7+
8+
describe("isoNowMinus", () => {
9+
it("returns the ISO timestamp windowMs before the injected now", () => {
10+
const now = Date.parse("2026-07-04T12:00:00.000Z");
11+
expect(isoNowMinus(15 * 60 * 1000, now)).toBe("2026-07-04T11:45:00.000Z");
12+
});
13+
14+
it("defaults to the current clock when no now is given", () => {
15+
// Default-parameter path: a window before "now" is always in the past.
16+
expect(isoNowMinus(1000) <= new Date().toISOString()).toBe(true);
17+
});
18+
19+
it("exposes a 15-minute default window", () => {
20+
expect(DLQ_RECENT_WINDOW_MS).toBe(900_000);
21+
});
22+
});
23+
24+
describe("sampleRecentDeadLetters", () => {
25+
it("returns the count over the trailing window, queried at the window start", async () => {
26+
const now = Date.parse("2026-07-04T12:00:00.000Z");
27+
const spy = vi.spyOn(repositories, "countRecentDeadLetters").mockResolvedValue(7);
28+
expect(await sampleRecentDeadLetters({} as Env, now)).toBe(7);
29+
expect(spy).toHaveBeenCalledWith({}, "2026-07-04T11:45:00.000Z");
30+
});
31+
32+
it("degrades to 0 when the query throws, so a DB hiccup never breaks the scrape", async () => {
33+
vi.spyOn(repositories, "countRecentDeadLetters").mockRejectedValue(new Error("db down"));
34+
expect(await sampleRecentDeadLetters({} as Env)).toBe(0);
35+
});
36+
});
37+
});

0 commit comments

Comments
 (0)