From 0564d86373a6812ceb2efdcbe7272458b610c408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 23 May 2026 10:12:09 +0200 Subject: [PATCH] chore(observability): surface iOS payload diff on widget + series validation fails Both dashboard.widgets.validation-failed and measurements.series.validation-failed currently emit only the Zod error count. Extend the wide-event meta with received_keys, a truncated received_shape_excerpt (256 chars max), and zod_issues so a single log line carries both the iOS-sent shape and the rejection reason. Closes H-iOS-1 + H-iOS-2 in v1.4.48 backlog. --- .../dashboard/widgets/__tests__/route.test.ts | 62 +++++++++++++++++++ src/app/api/dashboard/widgets/route.ts | 23 ++++++- .../series/__tests__/route.test.ts | 54 ++++++++++++++++ src/app/api/measurements/series/route.ts | 25 ++++++-- 4 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index da27da073..127f5d7e3 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -36,6 +36,15 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + annotate: vi.fn(), + }; +}); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -48,6 +57,7 @@ vi.mock("next/headers", () => ({ import { PUT, __resetAuditDedupMemoForTests } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { annotate } from "@/lib/logging/context"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; const SESSION_OK = { @@ -165,6 +175,58 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", expect(body2.details.issues.length).toBe(2); }); + it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta (v1.4.48 H-iOS-1)", async () => { + const payload = { version: 2, widgets: [], extraGarbage: "from-ios" }; + const res = await callPut(makeReq(payload)); + expect(res.status).toBe(422); + + const annotated = vi.mocked(annotate).mock.calls.find( + (call) => + (call[0] as { action?: { name?: string } })?.action?.name === + "dashboard.widgets.validation-failed", + ); + expect(annotated, "validation-failed annotate call").toBeTruthy(); + const meta = (annotated![0] as { meta?: Record }).meta!; + + // Top-level keys mirror the iOS-sent payload (NOT the schema keys). + expect(meta.received_keys).toEqual( + expect.arrayContaining(["version", "widgets", "extraGarbage"]), + ); + + // Excerpt is a JSON-stringified prefix, hard-capped at 256 chars. + expect(typeof meta.received_shape_excerpt).toBe("string"); + expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual( + 256, + ); + expect(meta.received_shape_excerpt as string).toContain("\"version\":2"); + + // zod_issues is the same sanitised array surfaced under details.issues. + const issues = meta.zod_issues as Array<{ path: string; code: string }>; + expect(Array.isArray(issues)).toBe(true); + expect(issues.length).toBeGreaterThanOrEqual(2); + for (const issue of issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("caps received_shape_excerpt at 256 chars even for a large iOS payload", async () => { + const widgets = Array.from({ length: 30 }, (_, i) => ({ + id: `widget-${i}-${"x".repeat(20)}`, + visible: true, + order: i, + })); + const res = await callPut(makeReq({ version: 99, widgets })); + expect(res.status).toBe(422); + + const annotated = vi.mocked(annotate).mock.calls.find( + (call) => + (call[0] as { action?: { name?: string } })?.action?.name === + "dashboard.widgets.validation-failed", + ); + const meta = (annotated![0] as { meta?: Record }).meta!; + expect((meta.received_shape_excerpt as string).length).toBe(256); + }); + it("does not block the 422 response when the audit-row write rejects", async () => { vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( new Error("db down"), diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index ca3873505..daa6339bb 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -145,9 +145,30 @@ export const PUT = apiHandler(async (request: NextRequest) => { // `/api/admin/audit` for these without combing the iOS dev // console. const issues = sanitiseZodIssues(parsed.error.issues); + // v1.4.48 H-iOS-1 — surface the iOS-sent payload shape alongside + // the Zod rejection so a single wide-event line carries enough + // detail to diagnose serialiser drift in `HealthLog-iOS`. We log + // ONLY the top-level keys plus a hard 256-char JSON excerpt — never + // the full body — so PII / token-like fields cannot leak. + const receivedKeys = + body && typeof body === "object" && !Array.isArray(body) + ? Object.keys(body as Record) + : []; + let excerpt: string; + try { + excerpt = JSON.stringify(body) ?? ""; + } catch { + excerpt = ""; + } + const receivedShapeExcerpt = excerpt.slice(0, 256); annotate({ action: { name: "dashboard.widgets.validation-failed" }, - meta: { issue_count: issues.length }, + meta: { + issue_count: issues.length, + received_keys: receivedKeys, + received_shape_excerpt: receivedShapeExcerpt, + zod_issues: issues, + }, }); // v1.4.43 B2 — gate the breadcrumb behind a 60 s (userId, action) dedup // so a misbehaving iOS client retrying every second cannot pump diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index f84dde299..695feece9 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -16,6 +16,15 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + annotate: vi.fn(), + }; +}); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -28,6 +37,7 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { annotate } from "@/lib/logging/context"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -157,6 +167,50 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () => expect(call.data.action).toBe("measurements.series.validation-failed"); }); + it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta (v1.4.48 H-iOS-2)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET(req("kind=garbage&days=0&extraGarbage=fromIos")); + expect(res.status).toBe(422); + + const annotated = vi.mocked(annotate).mock.calls.find( + (call) => + (call[0] as { action?: { name?: string } })?.action?.name === + "measurements.series.validation-failed", + ); + expect(annotated, "validation-failed annotate call").toBeTruthy(); + const meta = (annotated![0] as { meta?: Record }).meta!; + + expect(meta.received_keys).toEqual( + expect.arrayContaining(["kind", "days", "extraGarbage"]), + ); + expect(typeof meta.received_shape_excerpt).toBe("string"); + expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual( + 256, + ); + expect(meta.received_shape_excerpt as string).toContain("\"kind\":\"garbage\""); + + const issues = meta.zod_issues as Array<{ path: string; code: string }>; + expect(Array.isArray(issues)).toBe(true); + expect(issues.length).toBeGreaterThanOrEqual(2); + for (const issue of issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("caps received_shape_excerpt at 256 chars even for a long iOS query", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const long = "x".repeat(500); + const res = await GET(req(`kind=garbage&days=0&junk=${long}`)); + expect(res.status).toBe(422); + const annotated = vi.mocked(annotate).mock.calls.find( + (call) => + (call[0] as { action?: { name?: string } })?.action?.name === + "measurements.series.validation-failed", + ); + const meta = (annotated![0] as { meta?: Record }).meta!; + expect((meta.received_shape_excerpt as string).length).toBe(256); + }); + it("does not block the 422 when the audit-row write rejects", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 86a5a7f25..e7e243e7a 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -70,16 +70,33 @@ function stdDev(values: number[]): number { export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const parsed = querySchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams), - ); + const rawQuery = Object.fromEntries(request.nextUrl.searchParams); + const parsed = querySchema.safeParse(rawQuery); if (!parsed.success) { // v1.4.43 W6 — iOS chart loader hot path; multi-issue 422 + // audit breadcrumb keyed `measurements.series.validation-failed`. const issues = sanitiseZodIssues(parsed.error.issues); + // v1.4.48 H-iOS-2 — surface the iOS-sent query shape alongside + // the Zod rejection. Top-level keys + a hard 256-char JSON excerpt + // only; never the full payload, so token-shaped query params (we + // do not currently accept any but the truncation keeps that + // invariant cheap) cannot leak. + const receivedKeys = Object.keys(rawQuery); + let excerpt: string; + try { + excerpt = JSON.stringify(rawQuery) ?? ""; + } catch { + excerpt = ""; + } + const receivedShapeExcerpt = excerpt.slice(0, 256); annotate({ action: { name: "measurements.series.validation-failed" }, - meta: { issue_count: issues.length }, + meta: { + issue_count: issues.length, + received_keys: receivedKeys, + received_shape_excerpt: receivedShapeExcerpt, + zod_issues: issues, + }, }); prisma.auditLog .create({