diff --git a/src/db/repositories.ts b/src/db/repositories.ts index ef4dd93a82..722f8da4e9 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -2238,6 +2238,25 @@ export async function countRecentDeadLetters(env: Env, sinceIso: string): Promis return row.count; } +/** Observability for the DLQ dashboard (#1208): recent dead letters grouped by job type, using the jobType stored + * in each `github_app.dlq_dead_lettered` audit event's metadata. Missing/blank jobType falls back to `unknown`, + * and the returned object's keys are sorted deterministically for stable consumers/tests. */ +export async function countRecentDeadLettersByType(env: Env, sinceIso: string): Promise> { + const db = getDb(env.DB); + const jobTypeExpr = + sql`coalesce(nullif(trim(cast(json_extract(${auditEvents.metadataJson}, '$.jobType') as text)), ''), 'unknown')`; + const rows = await db + .select({ + jobType: jobTypeExpr, + count: sql`count(*)`, + }) + .from(auditEvents) + .where(and(eq(auditEvents.eventType, "github_app.dlq_dead_lettered"), gte(auditEvents.createdAt, sinceIso))) + .groupBy(jobTypeExpr) + .orderBy(asc(jobTypeExpr)); + return Object.fromEntries(rows.map((row) => [row.jobType, Number(row.count)])); +} + export type PrVisibilitySkipAuditEvent = { repoFullName: string; pullNumber: number; diff --git a/test/unit/db-parsers.test.ts b/test/unit/db-parsers.test.ts index 58d729c88f..4ff5bb26e3 100644 --- a/test/unit/db-parsers.test.ts +++ b/test/unit/db-parsers.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { claimRegateFanoutSlot, countRecentDeadLetters, + countRecentDeadLettersByType, countRecentAuditEventsForActorAndTarget, getLatestScorePreview, getRepoAuthorPullRequestHistory, @@ -373,6 +374,109 @@ describe("database row parser hardening", () => { expect(await countRecentDeadLetters(env, "2026-06-24T13:00:00.000Z")).toBe(0); // none after the cutoff → count(*) returns 0 }); + it("countRecentDeadLettersByType groups recent dead letters by job type in deterministic key order (#1208)", async () => { + const env = createTestEnv(); + await recordAuditEvent(env, { + eventType: "github_app.dlq_dead_lettered", + actor: "gittensory", + targetKey: "dlq:github-webhook:a", + outcome: "error", + createdAt: "2026-06-24T12:00:00.000Z", + metadata: { jobType: "github-webhook" }, + }); + await recordAuditEvent(env, { + eventType: "github_app.dlq_dead_lettered", + actor: "gittensory", + targetKey: "dlq:backfill-repo-segment:b", + outcome: "error", + createdAt: "2026-06-24T10:00:00.000Z", + metadata: { jobType: "backfill-repo-segment" }, + }); + await recordAuditEvent(env, { + eventType: "github_app.dlq_dead_lettered", + actor: "gittensory", + targetKey: "dlq:github-webhook:c", + outcome: "error", + createdAt: "2026-06-24T14:00:00.000Z", + metadata: { jobType: "github-webhook" }, + }); + + const counts = await countRecentDeadLettersByType(env, "2026-06-24T09:00:00.000Z"); + expect(counts).toEqual({ + "backfill-repo-segment": 1, + "github-webhook": 2, + }); + expect(Object.keys(counts)).toEqual(["backfill-repo-segment", "github-webhook"]); + }); + + it("countRecentDeadLettersByType returns a single grouped key when only one job type is present", async () => { + const env = createTestEnv(); + await recordAuditEvent(env, { + eventType: "github_app.dlq_dead_lettered", + actor: "gittensory", + targetKey: "dlq:refresh-registry:a", + outcome: "error", + createdAt: "2026-06-24T10:00:00.000Z", + metadata: { jobType: "refresh-registry" }, + }); + await recordAuditEvent(env, { + eventType: "github_app.dlq_dead_lettered", + actor: "gittensory", + targetKey: "dlq:refresh-registry:b", + outcome: "error", + createdAt: "2026-06-24T11:00:00.000Z", + metadata: { jobType: "refresh-registry" }, + }); + + expect(await countRecentDeadLettersByType(env, "2026-06-24T09:00:00.000Z")).toEqual({ + "refresh-registry": 2, + }); + }); + + it("countRecentDeadLettersByType returns an empty object when no recent dead letters exist", async () => { + const env = createTestEnv(); + await recordAuditEvent(env, { + eventType: "github_app.dlq_dead_lettered", + actor: "gittensory", + targetKey: "dlq:github-webhook:stale", + outcome: "error", + createdAt: "2026-06-24T08:59:59.000Z", + metadata: { jobType: "github-webhook" }, + }); + await recordAuditEvent(env, { + eventType: "agent.sweep.regate", + actor: "gittensory", + targetKey: "owner/repo", + outcome: "completed", + createdAt: "2026-06-24T12:00:00.000Z", + }); + + expect(await countRecentDeadLettersByType(env, "2026-06-24T09:00:00.000Z")).toEqual({}); + }); + + it("countRecentDeadLettersByType falls back missing or blank job types to unknown", async () => { + const env = createTestEnv(); + await recordAuditEvent(env, { + eventType: "github_app.dlq_dead_lettered", + actor: "gittensory", + targetKey: "dlq:unknown:a", + outcome: "error", + createdAt: "2026-06-24T10:00:00.000Z", + }); + await recordAuditEvent(env, { + eventType: "github_app.dlq_dead_lettered", + actor: "gittensory", + targetKey: "dlq:unknown:b", + outcome: "error", + createdAt: "2026-06-24T11:00:00.000Z", + metadata: { jobType: " " }, + }); + + expect(await countRecentDeadLettersByType(env, "2026-06-24T09:00:00.000Z")).toEqual({ + unknown: 2, + }); + }); + it("countRecentAuditEventsForActorAndTarget counts events scoped to ONE actor+eventType+targetKey since a cutoff (#2463)", async () => { const env = createTestEnv(); await recordAuditEvent(env, { eventType: "github_app.review_nag_ping", actor: "chatty", targetKey: "owner/repo#1", outcome: "completed", createdAt: "2026-06-24T10:00:00.000Z" });