From 20421e6d586261b2a01a7822ecc10ad3f277026c Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:35:50 +0200 Subject: [PATCH] Log which fields break an RPC output contract (#207) An 'Output validation failed' response was swallowed by the RPC error logger because it is an ORPCError, so the failing procedure and fields never reached the logs. Log those cases with the procedure and the field paths, without the payload. Claude-Session: https://claude.ai/code/session_01LFxDxzmAy9mJnKWoBrCtRB Co-authored-by: Claude --- apps/api/src/app.ts | 15 ++++++++++-- apps/api/src/log-unexpected-rpc-error.test.ts | 24 ++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8cfbd91..fa61f56 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { ORPCError, onError } from "@orpc/server"; +import { ORPCError, onError, ValidationError } from "@orpc/server"; import { RPCHandler } from "@orpc/server/fetch"; import type { AgentRuntime, @@ -1048,7 +1048,18 @@ function sessionHeaders(request: Request) { * "fetch failed" and keep the host and errno one level down. */ export function logUnexpectedRpcError(error: unknown, path: readonly string[]): void { - if (error instanceof ORPCError) return; const where = `rpc ${path.join("/")} failed`; + if (error instanceof ORPCError) { + // A handler result that breaks its output contract is a server bug, not a chosen error. + if (error.code !== "INTERNAL_SERVER_ERROR" || !(error.cause instanceof ValidationError)) return; + const issues = error.cause.issues.map((issue) => ({ + path: (issue.path ?? []) + .map((segment) => (typeof segment === "object" ? segment.key : segment)) + .join("."), + problem: issue.message, + })); + getLogger().error(where, error, { issues }); + return; + } getLogger().error(where, error); } diff --git a/apps/api/src/log-unexpected-rpc-error.test.ts b/apps/api/src/log-unexpected-rpc-error.test.ts index 2e154cf..417077f 100644 --- a/apps/api/src/log-unexpected-rpc-error.test.ts +++ b/apps/api/src/log-unexpected-rpc-error.test.ts @@ -1,4 +1,4 @@ -import { ORPCError } from "@orpc/server"; +import { ORPCError, ValidationError } from "@orpc/server"; import { createLogger, createTestSink, installLogger } from "@rakazo/logging"; import { afterEach, describe, expect, it } from "vitest"; import { logUnexpectedRpcError } from "./app.js"; @@ -20,6 +20,28 @@ describe("logUnexpectedRpcError", () => { expect(sink.events).toEqual([]); }); + it("names the procedure and the fields when a result breaks its output contract", () => { + const sink = createTestSink(); + installLogger(createLogger({ service: "rakazo-api", sinks: [sink] })); + const error = new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Output validation failed", + cause: new ValidationError({ + message: "Output validation failed", + issues: [{ message: "Expected string", path: ["items", 0, { key: "title" }] }], + data: { items: [{ title: null, body: "private" }] }, + }), + }); + + logUnexpectedRpcError(error, ["inbox", "list"]); + + expect(sink.events).toHaveLength(1); + expect(sink.events[0]).toMatchObject({ + message: "rpc inbox/list failed", + issues: [{ path: "items.0.title", problem: "Expected string" }], + }); + expect(JSON.stringify(sink.events[0])).not.toContain("private"); + }); + it("names the procedure and every cause behind an opaque failure", () => { const sink = createTestSink(); installLogger(createLogger({ service: "rakazo-api", sinks: [sink] }));