From 2abc78ab6daa0277178d0b6e5a304d4fa540b3f2 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 5 Sep 2026 01:29:54 +0200 Subject: [PATCH] web: send X-Correlation-ID on every API call, read the error envelope, show the reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authenticatedFetch mints one UUID per logical call (kept across the 401 retry, where caller headers now survive too), sends it as X-Correlation-ID — the id the backend echoes, stamps on its request row and writes into every error body — and tracks api_request_failed {path, method, status, correlationId} for any final status >= 400. App Insights gets enableCorsCorrelation + W3C distributedTracingMode scoped to the API host, so portal dependency rows and backend request rows share an operation_Id. fetchJson/ApiError read the envelope (error, code, correlationId, hint; the pre-envelope `message` as fallback), describeApiError renders message + short reference, addNotification takes an optional reference the bell renders as "Ref …"; the error boundaries show and track Next's digest. Consumers of renamed fields follow: delete-session parser (code ?? hint, PascalCase codes), app-homing (code ?? reason). API_ERROR_CODES comes from the manifest, and HOME_TENANT_UNRESOLVED plus the cascade codes are `satisfies ApiErrorCode`. InstallsTab is the first site showing the reference. Co-Authored-By: Claude Fable 5.1 --- .../components/TenantManagementSection.tsx | 6 +- .../app/apps/components/InstallsTab.tsx | 5 +- .../dashboard/hooks/deleteSessionResponse.ts | 28 +++++--- src/Web/autopilot-monitor-web/app/error.tsx | 8 +++ .../app/global-error.tsx | 6 ++ .../components/Navbar.tsx | 3 + .../contexts/NotificationContext.tsx | 8 ++- .../lib/__tests__/authenticatedFetch.test.ts | 60 ++++++++++++++++ .../lib/__tests__/bulkActions.test.ts | 2 +- .../lib/__tests__/correlationId.test.ts | 23 ++++++ .../__tests__/deleteSessionResponse.test.ts | 20 +++++- .../lib/__tests__/scopedFetch.test.ts | 71 +++++++++++++++++++ .../lib/apiErrorCodes.ts | 11 +++ .../autopilot-monitor-web/lib/appInsights.ts | 24 ++++++- .../lib/authenticatedFetch.ts | 27 +++++++ .../lib/correlationId.ts | 28 ++++++++ .../lib/identityBinding.ts | 4 +- .../autopilot-monitor-web/lib/scopedFetch.ts | 60 +++++++++++++--- 18 files changed, 365 insertions(+), 29 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/correlationId.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/scopedFetch.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/apiErrorCodes.ts create mode 100644 src/Web/autopilot-monitor-web/lib/correlationId.ts diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx index 26c3d882..e33d850c 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx @@ -380,9 +380,11 @@ function TenantManagementSectionInner({ tenantId: tenant.tenantId, target, force, - reason: data.reason ?? `http-${response.status}`, + reason: data.code ?? data.reason ?? `http-${response.status}`, }); - throw new Error(appHomingErrorMessage(data.reason, response.statusText, data.probe?.missingRoles)); + // `code` is the error-envelope field (AppHomingDeniedResponse); `reason` is the pre-envelope + // name, read until the backend deploy that precedes this web build is everywhere. + throw new Error(appHomingErrorMessage(data.code ?? data.reason, response.statusText, data.probe?.missingRoles)); } trackEvent("app_homing_manual_flip", { tenantId: tenant.tenantId, diff --git a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx index 741d79a2..13fc25a4 100644 --- a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx @@ -8,6 +8,7 @@ import { useAuth } from "../../../contexts/AuthContext"; import { useNotifications } from "../../../contexts/NotificationContext"; import { scopedApi } from "@/lib/scopedApi"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorFromResponse, describeApiError } from "@/lib/scopedFetch"; import { formatBytes, formatDuration } from "@/lib/formatting"; import DoBreakdownBar from "@/components/DoBreakdownBar"; import { CalculatingInline } from "@/components/CalculatingCard"; @@ -119,7 +120,9 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { setData((await listRes.json()) as AppsListResponse); succeeded = true; } else { - addNotification("error", "Backend Error", `Failed to load apps: ${listRes.statusText}`, "apps-list-error"); + // Error envelope: the backend's message plus the correlation id as a quotable reference. + const { message, reference } = describeApiError(await apiErrorFromResponse(listRes)); + addNotification("error", "Backend Error", `Failed to load apps: ${message}`, "apps-list-error", undefined, reference ?? undefined); } if (metricsRes.ok) { const m = (await metricsRes.json()) as AppMetricsResponse; diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/deleteSessionResponse.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/deleteSessionResponse.ts index 9e614e06..2217e180 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/deleteSessionResponse.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/deleteSessionResponse.ts @@ -6,6 +6,7 @@ * The hook itself runs the side-effects (toast, SignalR group join, row removal); these * helpers describe *what* should happen for a given backend response. */ +import type { ApiErrorCode } from "@/lib/apiErrorCodes"; /** * Backend body shape for the V2 cascade 202 response. @@ -18,18 +19,29 @@ export interface DeleteQueuedBody { message?: string; } -/** Backend body shape for 4xx/5xx responses — same producer outcome contract. */ +/** + * Backend body shape for 4xx/5xx responses: the error envelope (`SessionDeletionRejectedResponse` + * — error, code, correlationId + the lock diagnostics). `message`/`hint` are the pre-envelope + * names, still read during the backend → web deploy window. + */ export interface DeleteErrorBody { - success?: boolean; - message?: string; + error?: string; + code?: string; + correlationId?: string; deletionState?: string; manifestId?: string; + /** @deprecated pre-envelope name of `error` */ + message?: string; + /** @deprecated pre-envelope name of `code` */ hint?: string; } +/** Envelope codes of the 409 arms (Constants.ApiErrorCodes) plus their pre-envelope `hint` spellings. */ +const POISONED_CODES: ReadonlySet = new Set(["CascadePoisonedUseRestore" satisfies ApiErrorCode, "cascade_poisoned_use_restore"]); + export type DeleteResponseAction = | { kind: "queued"; sessionId: string; tenantId: string; manifestId: string | null } - | { kind: "conflict"; sessionId: string; title: string; message: string; hint: string | null } + | { kind: "conflict"; sessionId: string; title: string; message: string; code: string | null } | { kind: "unavailable"; sessionId: string; message: string } | { kind: "notFound"; sessionId: string; message: string } | { kind: "error"; sessionId: string; message: string }; @@ -56,14 +68,14 @@ export async function classifyDeleteResponse( } const errorBody = await safeJson(response); - const message = errorBody?.message ?? `HTTP ${response.status}`; + const message = errorBody?.error ?? errorBody?.message ?? `HTTP ${response.status}`; if (response.status === 409) { - const hint = errorBody?.hint ?? null; - const title = hint === "cascade_poisoned_use_restore" + const code = errorBody?.code ?? errorBody?.hint ?? null; + const title = code !== null && POISONED_CODES.has(code) ? "Cascade poisoned" : "Cascade already in flight"; - return { kind: "conflict", sessionId, title, message, hint }; + return { kind: "conflict", sessionId, title, message, code }; } if (response.status === 503) { diff --git a/src/Web/autopilot-monitor-web/app/error.tsx b/src/Web/autopilot-monitor-web/app/error.tsx index dd5416a9..74595389 100644 --- a/src/Web/autopilot-monitor-web/app/error.tsx +++ b/src/Web/autopilot-monitor-web/app/error.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { isChunkLoadError, tryRecoverFromChunkError } from "@/utils/chunkReloadRecovery"; +import { trackEvent } from "@/lib/appInsights"; /** * Route-level error boundary — catches unhandled exceptions within page @@ -25,7 +26,11 @@ export default function Error({ useEffect(() => { if (isChunkLoadError(error)) { tryRecoverFromChunkError("error-boundary"); + return; } + // The digest is Next's stable hash of the error (also in the server log) — the only + // reference a user can quote for a client-side crash; the event makes it searchable. + trackEvent("client_error_boundary", { digest: error.digest ?? "", name: error.name }); }, [error]); return ( @@ -36,6 +41,9 @@ export default function Error({

Your session may have expired. Try reloading the page or signing in again.

+ {error.digest && ( +

Ref {error.digest}

+ )}