feat(hk): connect delivery failures to precise diagnostic records
CI / check (push) Successful in 3m31s
CI / deploy (push) Successful in 18s
CI / publish-container (push) Successful in 1m12s

This commit is contained in:
Simo committed 2026-09-13 20:25:03 +02:00
1 parent 275a574203
commit 60e49c1ec9
17 files changed
+253 -20

No files matched your search

+4 -1
View File
@@ -31,7 +31,10 @@ test("deliveries explain outcomes without exposing unauthorized content or raw m
).toHaveAttribute("href", "/admin/articles/9007199254740993"); ).toHaveAttribute("href", "/admin/articles/9007199254740993");
await expect( await expect(
news.getByRole("link", { name: "Service diagnostics", exact: true }), news.getByRole("link", { name: "Service diagnostics", exact: true }),
).toHaveAttribute("href", "/admin/devops/installation"); ).toHaveAttribute(
"href",
"/admin/devops/cms-errors?q=4f3df172-f1ca-4c26-a23e-cb4e6c13e9f2",
);
const failed = cards.nth(1); const failed = cards.nth(1);
await expect( await expect(
failed.getByRole("heading", { name: "9 catalog offers", exact: true }), failed.getByRole("heading", { name: "9 catalog offers", exact: true }),
+1
View File
@@ -19,6 +19,7 @@ const records: DeliveryCardItem[] = [
{ {
...shared, ...shared,
id: "delivery-authorized-news-1234567890", id: "delivery-authorized-news-1234567890",
errorId: "4f3df172-f1ca-4c26-a23e-cb4e6c13e9f2",
operationId: "operation-authorized-news-1234567890", operationId: "operation-authorized-news-1234567890",
context: deliveryContext( context: deliveryContext(
"news.update", "news.update",
@@ -1,3 +1,4 @@
import { renderToStaticMarkup } from "react-dom/server";
import { expect, it, vi } from "vitest"; import { expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ read: vi.fn(), access: vi.fn() })); const mocks = vi.hoisted(() => ({ read: vi.fn(), access: vi.fn() }));
@@ -21,6 +22,14 @@ vi.mock("@/lib/error-monitor", () => ({
})); }));
vi.mock("@/components/link", () => ({ default: () => null })); vi.mock("@/components/link", () => ({ default: () => null }));
vi.mock("next-intl/server", () => ({
getTranslations: async () => (key: string) => key,
}));
vi.mock("./actions", () => ({
assignCmsError: vi.fn(),
resolveCmsError: vi.fn(),
}));
import Page from "./page"; import Page from "./page";
it("rejects access before reading diagnostic data", async () => { it("rejects access before reading diagnostic data", async () => {
@@ -30,3 +39,39 @@ it("rejects access before reading diagnostic data", async () => {
); );
expect(mocks.read).not.toHaveBeenCalled(); expect(mocks.read).not.toHaveBeenCalled();
}); });
it("shows the referenced occurrence while retaining the current group resolution", async () => {
mocks.access.mockImplementation(
(_permissions, permission) => permission === "devops.view",
);
const old = {
id: "old-reference",
fingerprint: "same-group",
at: "2026-09-10T10:00:00Z",
release: "old-release",
source: "server",
event: "Delivery failed",
message: "Failure",
stack: "OLDER_ERROR_STACK",
context: { deliveryId: "older-delivery" },
resolved: false,
};
const newer = {
...old,
id: "new-reference",
at: "2026-09-11T10:00:00Z",
release: "new-release",
stack: "NEWER_ERROR_STACK",
context: { deliveryId: "newer-delivery" },
resolved: true,
};
mocks.read.mockResolvedValue({ records: [old, newer], truncated: false });
const html = renderToStaticMarkup(
await Page({ searchParams: Promise.resolve({ q: "old-reference" }) }),
);
expect(html).toContain("OLDER_ERROR_STACK");
expect(html).toContain("older-delivery");
expect(html).not.toContain("NEWER_ERROR_STACK");
expect(html).not.toContain("newer-delivery");
expect(html).toContain("resolved");
});
+8 -3
View File
@@ -159,9 +159,14 @@ export default async function CmsErrorsPage({
.map(([fingerprint, events]) => { .map(([fingerprint, events]) => {
const metrics = summarizeErrorGroup(events); const metrics = summarizeErrorGroup(events);
const r = latestByGroup.get(fingerprint); const groupLatest = latestByGroup.get(fingerprint);
if (!groupLatest || !metrics.latest) return null;
if (!r) return null; // Details follow the selected occurrence; triage belongs to the whole group.
const r = {
...metrics.latest,
resolved: groupLatest.resolved,
assignment: groupLatest.assignment,
};
return ( return (
<details key={fingerprint} className="admin-card overflow-hidden"> <details key={fingerprint} className="admin-card overflow-hidden">
+27 -3
View File
@@ -3,10 +3,18 @@ import { getTranslations } from "next-intl/server";
import Link from "@/components/link"; import Link from "@/components/link";
import { DeliveryCard } from "@/features/operations/delivery-card"; import { DeliveryCard } from "@/features/operations/delivery-card";
import { deliveryContext } from "@/features/operations/delivery-context"; import { deliveryContext } from "@/features/operations/delivery-context";
import {
deliveryDiagnosticId,
isDeliveryReference,
} from "@/features/operations/delivery-diagnostics";
import { DeliveryRetry } from "@/features/operations/retry-button"; import { DeliveryRetry } from "@/features/operations/retry-button";
import { listEffects } from "@/features/operations/server"; import { listEffects } from "@/features/operations/server";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function DeliveryPage() { export default async function DeliveryPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
redirect("/admin"); redirect("/admin");
@@ -16,9 +24,17 @@ export default async function DeliveryPage() {
catalog: canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank), catalog: canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank),
}; };
const t = await getTranslations("pages.admin.deliveries"); const t = await getTranslations("pages.admin.deliveries");
let selectedId: string | undefined;
let items: Awaited<ReturnType<typeof listEffects>> | null = null; let items: Awaited<ReturnType<typeof listEffects>> | null = null;
try { try {
items = await listEffects(); const params = await searchParams;
selectedId =
typeof params.id === "string"
? params.id
: params.id === undefined
? undefined
: "invalid";
items = await listEffects(selectedId);
} catch { } catch {
/* Preserve the distinction between unavailable and empty history. */ /* Preserve the distinction between unavailable and empty history. */
} }
@@ -29,7 +45,14 @@ export default async function DeliveryPage() {
<h1 className="text-2xl font-semibold">{t("title")}</h1> <h1 className="text-2xl font-semibold">{t("title")}</h1>
<p className="text-muted-foreground">{t("scope")}</p> <p className="text-muted-foreground">{t("scope")}</p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link href="/admin/devops/deliveries" className="btn btn-outline"> <Link
href={
isDeliveryReference(selectedId)
? `/admin/devops/deliveries?id=${selectedId}`
: "/admin/devops/deliveries"
}
className="btn btn-outline"
>
{t("refresh")} {t("refresh")}
</Link> </Link>
{canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank) && ( {canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank) && (
@@ -59,6 +82,7 @@ export default async function DeliveryPage() {
createdAt: timestamp(item.createdAt), createdAt: timestamp(item.createdAt),
availableAt: timestamp(item.availableAt), availableAt: timestamp(item.availableAt),
hasError: !!item.lastError, hasError: !!item.lastError,
errorId: deliveryDiagnosticId(item.lastError),
context: deliveryContext(item.kind, item.resultJson, access), context: deliveryContext(item.kind, item.resultJson, access),
}} }}
retry={ retry={
+13
View File
@@ -14,3 +14,16 @@ The delivery screen separates saved content from its subsequent background updat
Content titles and links require the corresponding NEWS_VIEW or CATALOG_VIEW grant in addition to DEVOPS_VIEW. Raw result JSON and internal error text are never forwarded to the card. Each record shows its recorded time, staff/system actor, attempts, and scheduled retry time when applicable. Failures point to service diagnostics; exhausted attempts expose the existing permission-protected retry without repeating the content mutation. Delivery success does not imply Git publication or client refresh acknowledgement. Content titles and links require the corresponding NEWS_VIEW or CATALOG_VIEW grant in addition to DEVOPS_VIEW. Raw result JSON and internal error text are never forwarded to the card. Each record shows its recorded time, staff/system actor, attempts, and scheduled retry time when applicable. Failures point to service diagnostics; exhausted attempts expose the existing permission-protected retry without repeating the content mutation. Delivery success does not imply Git publication or client refresh acknowledgement.
Browser fixtures verify the real cards on desktop/mobile, including long titles, denied metadata, exact links, unknown states and collapsed technical references. The fixture retry slot does not execute a backend action; backend authorization and state changes have separate tests. Browser fixtures verify the real cards on desktop/mobile, including long titles, denied metadata, exact links, unknown states and collapsed technical references. The fixture retry slot does not execute a backend action; backend authorization and state changes have separate tests.
Delivery failures retain the original exception in the redacted CMS diagnostic store,
alongside a delivery UUID and the persisted operation UUID. Request correlation is
kept separately. Only the diagnostic UUID is stored in the outbox error field.
The delivery card opens the matching error record; that record links back to the
exact delivery, including records older than the latest 100 entries. Refresh keeps
this selection. A historical diagnostic lookup shows that occurrence's context and
stack while retaining the current group's assignment and resolution status.
These links require existing DevOps access. Expired diagnostic records may no longer
be available after the error store's retention window; the delivery state remains
in SQL. Retry continues to require edit permission and an exhausted delivery.
+6 -1
View File
@@ -2,6 +2,7 @@ import { useFormatter, useTranslations } from "next-intl";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Link from "@/components/link"; import Link from "@/components/link";
import { deliveryTopic } from "./delivery-context"; import { deliveryTopic } from "./delivery-context";
import { deliveryDiagnosticLink } from "./delivery-diagnostics";
export interface DeliveryCardItem { export interface DeliveryCardItem {
id: string; id: string;
@@ -13,6 +14,7 @@ export interface DeliveryCardItem {
createdAt: string; createdAt: string;
availableAt: string; availableAt: string;
hasError: boolean; hasError: boolean;
errorId?: string | null;
context: { context: {
title: string | null; title: string | null;
href: string | null; href: string | null;
@@ -87,7 +89,10 @@ export function DeliveryCard({
</Link> </Link>
)} )}
{item.hasError && state !== "done" && ( {item.hasError && state !== "done" && (
<Link href="/admin/devops/installation" className="btn btn-outline"> <Link
href={deliveryDiagnosticLink(item.errorId)}
className="btn btn-outline"
>
{t("diagnostics")} {t("diagnostics")}
</Link> </Link>
)} )}
@@ -0,0 +1,22 @@
import { expect, it } from "vitest";
import {
deliveryDiagnosticId,
deliveryDiagnosticLink,
} from "./delivery-diagnostics";
it("uses only an exact server-issued reference for a diagnostic link", () => {
const id = "4f3df172-f1ca-4c26-a23e-cb4e6c13e9f2";
expect(deliveryDiagnosticId(`Diagnostic reference: ${id}`)).toBe(id);
expect(deliveryDiagnosticLink(id)).toBe(`/admin/devops/cms-errors?q=${id}`);
for (const input of [
null,
"",
"private SQL",
"Diagnostic reference: ../settings",
`Diagnostic reference: ${id}?secret=value`,
`Diagnostic reference: ${id}\n`,
]) {
expect(deliveryDiagnosticId(input)).toBeNull();
expect(deliveryDiagnosticLink(input)).toBe("/admin/devops/installation");
}
});
@@ -0,0 +1,19 @@
/** Only server-issued UUID references can become diagnostic/navigation targets. */
export function isDeliveryReference(value: unknown): value is string {
return (
typeof value === "string" &&
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(
value,
)
);
}
export function deliveryDiagnosticId(value: string | null): string | null {
const prefix = "Diagnostic reference: ";
const id = value?.startsWith(prefix) ? value.slice(prefix.length) : null;
return isDeliveryReference(id) ? id : null;
}
export function deliveryDiagnosticLink(errorId: unknown): string {
return isDeliveryReference(errorId)
? `/admin/devops/cms-errors?q=${encodeURIComponent(errorId)}`
: "/admin/devops/installation";
}
@@ -0,0 +1,53 @@
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ execute: vi.fn(), error: vi.fn() }));
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } }));
vi.mock("@/lib/logger", () => ({ logger: { error: mocks.error } }));
import { effectRepository, listEffects } from "./server";
const id = "840f4023-04cd-4d7b-92c5-047aa620029c";
const operationId = "1dd40f61-b710-48c8-8534-c3a3f726780a";
const errorId = "4f3df172-f1ca-4c26-a23e-cb4e6c13e9f2";
beforeEach(() => {
vi.resetAllMocks();
mocks.error.mockReturnValue(errorId);
mocks.execute.mockResolvedValue([[]]);
});
it("records the original failure with durable identity and stores only a safe diagnostic reference", async () => {
const error = new Error("private transport details");
await effectRepository.fail(
{
id,
operationId,
token: "private-lease",
topic: "news.refresh",
attempts: 2,
},
error,
);
expect(mocks.error).toHaveBeenCalledWith(
"Operation delivery failed",
expect.objectContaining({
module: "operations",
deliveryId: id,
durableOperationId: operationId,
error,
}),
);
const query = new MySqlDialect().sqlToQuery(mocks.execute.mock.calls[0][0]);
expect(query.params).toContain(`Diagnostic reference: ${errorId}`);
expect(query.params).not.toContain(error.message);
expect(query.params).toContain("private-lease");
});
it("looks up an older delivery by exact ID independently of the latest-page limit", async () => {
await listEffects(id);
const query = new MySqlDialect().sqlToQuery(mocks.execute.mock.calls[0][0]);
expect(query.sql).toContain("WHERE e.id=?");
expect(query.params).toContain(id);
});
it("does not query the database for a malformed delivery reference", async () => {
await expect(listEffects("../settings")).resolves.toEqual([]);
expect(mocks.execute).not.toHaveBeenCalled();
});
+3 -2
View File
@@ -31,8 +31,9 @@ it("persists failures and continues to other pending work", async () => {
complete: vi.fn(), complete: vi.fn(),
fail: vi.fn(), fail: vi.fn(),
}; };
await dispatchEffects(repo, vi.fn().mockRejectedValue(Error("private"))); const failure = Error("private");
expect(repo.fail).toHaveBeenCalledWith(effect); await dispatchEffects(repo, vi.fn().mockRejectedValue(failure));
expect(repo.fail).toHaveBeenCalledWith(effect, failure);
expect(repo.complete).not.toHaveBeenCalled(); expect(repo.complete).not.toHaveBeenCalled();
}); });
it("bounds work per tick instead of draining indefinitely", async () => { it("bounds work per tick instead of draining indefinitely", async () => {
+3 -3
View File
@@ -2,7 +2,7 @@ import type { EffectClaim } from "./model";
export interface EffectRepository { export interface EffectRepository {
claim(): Promise<EffectClaim | null>; claim(): Promise<EffectClaim | null>;
complete(claim: EffectClaim): Promise<unknown>; complete(claim: EffectClaim): Promise<unknown>;
fail(claim: EffectClaim): Promise<unknown>; fail(claim: EffectClaim, error: unknown): Promise<unknown>;
} }
/** At-least-once delivery: handlers must tolerate replay after an expired claim. */ /** At-least-once delivery: handlers must tolerate replay after an expired claim. */
export async function dispatchEffects( export async function dispatchEffects(
@@ -14,8 +14,8 @@ export async function dispatchEffects(
if (!claim) return; if (!claim) return;
try { try {
await deliver(claim); await deliver(claim);
} catch { } catch (error) {
await repo.fail(claim); await repo.fail(claim, error);
continue; continue;
} }
await repo.complete(claim); await repo.complete(claim);
+1
View File
@@ -59,6 +59,7 @@ export type EffectTopic =
| "news.refresh"; | "news.refresh";
export interface EffectClaim { export interface EffectClaim {
id: string; id: string;
operationId?: string;
token: string; token: string;
topic: EffectTopic; topic: EffectTopic;
attempts: number; attempts: number;
+21 -5
View File
@@ -2,6 +2,8 @@ import "server-only";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { logger } from "@/lib/logger";
import { isDeliveryReference } from "./delivery-diagnostics";
import { import {
type EffectClaim, type EffectClaim,
type EffectTopic, type EffectTopic,
@@ -60,11 +62,12 @@ export const effectRepository = {
async claim(): Promise<EffectClaim | null> { async claim(): Promise<EffectClaim | null> {
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const [rows] = await tx.execute( const [rows] = await tx.execute(
sql`SELECT id,topic,attempts FROM cms_outbox WHERE (status='pending' AND available_at<=UTC_TIMESTAMP(3)) OR (status='running' AND lease_until<UTC_TIMESTAMP(3)) ORDER BY available_at,id LIMIT 1 FOR UPDATE`, sql`SELECT id,operation_id AS operationId,topic,attempts FROM cms_outbox WHERE (status='pending' AND available_at<=UTC_TIMESTAMP(3)) OR (status='running' AND lease_until<UTC_TIMESTAMP(3)) ORDER BY available_at,id LIMIT 1 FOR UPDATE`,
); );
const row = ( const row = (
rows as unknown as Array<{ rows as unknown as Array<{
id: string; id: string;
operationId: string;
topic: EffectTopic; topic: EffectTopic;
attempts: number; attempts: number;
}> }>
@@ -82,15 +85,28 @@ export const effectRepository = {
sql`UPDATE cms_outbox SET status='done',lease_token=NULL,lease_until=NULL,last_error=NULL WHERE id=${claim.id} AND status='running' AND lease_token=${claim.token}`, sql`UPDATE cms_outbox SET status='done',lease_token=NULL,lease_until=NULL,last_error=NULL WHERE id=${claim.id} AND status='running' AND lease_token=${claim.token}`,
); );
}, },
async fail(claim: EffectClaim) { async fail(claim: EffectClaim, error: unknown) {
const errorId = logger.error("Operation delivery failed", {
module: "operations",
deliveryId: claim.id,
durableOperationId: claim.operationId,
topic: claim.topic,
attempt: claim.attempts,
error,
});
const reference = isDeliveryReference(errorId)
? `Diagnostic reference: ${errorId}`
: "Delivery failed; service diagnostics unavailable.";
await db.execute( await db.execute(
sql`UPDATE cms_outbox SET status=${claim.attempts >= 8 ? "failed" : "pending"},available_at=DATE_ADD(UTC_TIMESTAMP(3),INTERVAL ${retryDelay(claim.attempts)} SECOND),lease_token=NULL,lease_until=NULL,last_error='Delivery failed; inspect the linked operation and service diagnostics.' WHERE id=${claim.id} AND status='running' AND lease_token=${claim.token}`, sql`UPDATE cms_outbox SET status=${claim.attempts >= 8 ? "failed" : "pending"},available_at=DATE_ADD(UTC_TIMESTAMP(3),INTERVAL ${retryDelay(claim.attempts)} SECOND),lease_token=NULL,lease_until=NULL,last_error=${reference} WHERE id=${claim.id} AND status='running' AND lease_token=${claim.token}`,
); );
}, },
}; };
export async function listEffects() { export async function listEffects(id?: string) {
if (id !== undefined && !isDeliveryReference(id)) return [];
const filter = id === undefined ? sql`` : sql`WHERE e.id=${id}`;
const [rows] = await db.execute( const [rows] = await db.execute(
sql`SELECT e.id,e.operation_id AS operationId,e.topic,e.status,e.attempts,e.last_error AS lastError,e.created_at AS createdAt,e.available_at AS availableAt,o.actor_id AS actorId,o.kind,o.result_json AS resultJson FROM cms_outbox e JOIN cms_operations o ON o.id=e.operation_id ORDER BY e.created_at DESC,e.id DESC LIMIT 100`, sql`SELECT e.id,e.operation_id AS operationId,e.topic,e.status,e.attempts,e.last_error AS lastError,e.created_at AS createdAt,e.available_at AS availableAt,o.actor_id AS actorId,o.kind,o.result_json AS resultJson FROM cms_outbox e JOIN cms_operations o ON o.id=e.operation_id ${filter} ORDER BY e.created_at DESC,e.id DESC LIMIT 100`,
); );
return rows as unknown as Array<{ return rows as unknown as Array<{
id: string; id: string;
+3 -2
View File
@@ -49,8 +49,9 @@ it("invalidates the shared news cache before acknowledging delivery", async () =
expect(mocks.fail).not.toHaveBeenCalled(); expect(mocks.fail).not.toHaveBeenCalled();
}); });
it("keeps a failed news invalidation retryable", async () => { it("keeps a failed news invalidation retryable", async () => {
mocks.news.mockRejectedValue(new Error("Redis unavailable")); const error = new Error("Redis unavailable");
mocks.news.mockRejectedValue(error);
await drainOperationEffects(); await drainOperationEffects();
expect(mocks.fail).toHaveBeenCalledWith(effect); expect(mocks.fail).toHaveBeenCalledWith(effect, error);
expect(mocks.complete).not.toHaveBeenCalled(); expect(mocks.complete).not.toHaveBeenCalled();
}); });
+18
View File
@@ -75,3 +75,21 @@ it("links server operation IDs to the audit search without trusting browser IDs"
]).auditLink, ]).auditLink,
).toBeNull(); ).toBeNull();
}); });
it("links a server delivery error to its exact persisted delivery only", () => {
const id = "840f4023-04cd-4d7b-92c5-047aa620029c";
const record = createErrorRecord(
"server",
"Delivery failed",
"private failure",
{ module: "operations", deliveryId: id },
);
expect(errorOperationLink(record)).toBe(`/admin/devops/deliveries?id=${id}`);
expect(errorOperationLink({ ...record, source: "browser" })).toBeNull();
expect(
errorOperationLink({
...record,
context: { module: "operations", deliveryId: "../settings" },
}),
).toBeNull();
});
+6
View File
@@ -1,8 +1,14 @@
import { isDeliveryReference } from "@/features/operations/delivery-diagnostics";
import type { CmsErrorRecord } from "./error-monitor"; import type { CmsErrorRecord } from "./error-monitor";
export function errorOperationLink(record: CmsErrorRecord): string | null { export function errorOperationLink(record: CmsErrorRecord): string | null {
const context = record.context; const context = record.context;
if (record.source === "server") { if (record.source === "server") {
if (
context.module === "operations" &&
isDeliveryReference(context.deliveryId)
)
return `/admin/devops/deliveries?id=${context.deliveryId}`;
if (context.module === "news") { if (context.module === "news") {
const id = String(context.articleId ?? ""); const id = String(context.articleId ?? "");
return /^[1-9]\d{0,18}$/.test(id) return /^[1-9]\d{0,18}$/.test(id)