{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "middleware-trpc", "title": "tRPC Middleware", "description": "tRPC middleware that annotates the ambient request wide event with trpc fields (pair with the Next.js route-handler wrapper).", "type": "registry:lib", "docs": "Wire into your procedure bases: `const amplioMiddleware = t.middleware(amplioTrpcMiddleware())` then `.use(amplioMiddleware)` on publicProcedure/protectedProcedure. In a create-t3-app layout, `npx @useamplio/cli@alpha init --wire` edits src/server/api/trpc.ts for you.", "dependencies": [ "@useamplio/amplio@^0.1.0-alpha.15" ], "files": [ { "path": "registry/middleware/trpc.ts", "target": "~/telemetry/middleware/trpc.ts", "type": "registry:lib", "content": "/**\n * Amplio tRPC middleware. Like create-t3-app's own trpc.ts, you likely never\n * need to read or modify this file — wire amplioTrpcMiddleware() into your\n * procedure bases and you're done.\n *\n * Product stance:\n * ONE wide event per unit of work: the HTTP wrapper (withAmplio) owns the request\n * wide event (the spine). This middleware ANNOTATES that event with trpc.* fields\n * instead of emitting a sibling record. Domain events emitted in procedures are\n * intentionally separate rows — do not duplicate fields across them.\n */\n// Side-effect import: ensures init() from telemetry/logger runs in every module\n// graph that uses this middleware (next dev --turbo compiles instrumentation.ts\n// and route bundles separately, which would otherwise drop events silently).\nimport \"../logger\";\n\nimport {\n createLogger,\n createRequestId,\n getLogger,\n hasAmbientLogger,\n runWithLogger,\n scheduleFlush,\n trpcErrorHttpStatus,\n type Logger,\n} from \"@useamplio/amplio\";\n\ntype TrpcProcedureType = \"query\" | \"mutation\" | \"subscription\";\n\ntype TrpcProcedureRef = { path: string; type: TrpcProcedureType };\n\nconst batchedProcedures = new WeakMap();\n\nfunction isFailedMiddlewareResult(\n result: unknown,\n): result is { ok: false; error: unknown } {\n return (\n result !== null &&\n typeof result === \"object\" &&\n \"ok\" in result &&\n (result as { ok: unknown }).ok === false &&\n \"error\" in result\n );\n}\n\nfunction annotateTrpcProcedure(\n logger: Logger,\n path: string,\n type: TrpcProcedureType,\n): void {\n if (logger.sealed) {\n return;\n }\n\n // Count every invocation — a batch of two calls to the SAME procedure is two\n // units of work and must not be deduplicated away.\n const entry: TrpcProcedureRef = { path, type };\n const seen = batchedProcedures.get(logger) ?? [];\n const updated = [...seen, entry];\n batchedProcedures.set(logger, updated);\n\n if (updated.length === 1) {\n logger.set({ trpc: { path, type } });\n return;\n }\n\n // A batch has no single path — null the scalar fields so dashboards that\n // group by trpc.path never mix \"the request was for X\" with \"X happened to\n // be first in a batch\". The full list lives in trpc.procedures; a failing\n // procedure lands in trpc.failed_path (see annotateTrpcError).\n logger.set({\n trpc: {\n path: null,\n type: null,\n batched: true,\n batch_size: updated.length,\n procedures: updated.map((item) => `${item.type} ${item.path}`),\n },\n });\n}\n\nfunction isBatchedRequest(logger: Logger): boolean {\n return (batchedProcedures.get(logger)?.length ?? 0) > 1;\n}\n\ntype ValidationIssue = { path: Array; message: string };\n\n// TRPCError wraps input-validation failures in a ZodError cause whose message\n// is the full pretty-printed issue list — a multiline JSON blob that is hostile\n// to columnar stores and log search. Detect that shape so the event can carry\n// structured error.issues plus a short error.message instead.\nfunction zodValidationIssues(error: unknown): ValidationIssue[] | null {\n if (error === null || typeof error !== \"object\") {\n return null;\n }\n const cause = (error as { cause?: unknown }).cause;\n if (cause === null || typeof cause !== \"object\") {\n return null;\n }\n const issues = (cause as { issues?: unknown }).issues;\n if (!Array.isArray(issues) || issues.length === 0) {\n return null;\n }\n const mapped: ValidationIssue[] = [];\n for (const issue of issues) {\n if (issue === null || typeof issue !== \"object\") {\n return null;\n }\n const { path, message } = issue as { path?: unknown; message?: unknown };\n if (typeof message !== \"string\" || !Array.isArray(path)) {\n return null;\n }\n mapped.push({\n path: path.filter(\n (segment): segment is string | number =>\n typeof segment === \"string\" || typeof segment === \"number\",\n ),\n message,\n });\n }\n return mapped;\n}\n\nfunction shortValidationMessage(issues: ValidationIssue[]): string {\n const parts = issues\n .slice(0, 3)\n .map((issue) =>\n issue.path.length > 0 ? `${issue.path.join(\".\")}: ${issue.message}` : issue.message,\n );\n const suffix = issues.length > 3 ? ` (+${issues.length - 3} more)` : \"\";\n return `input validation failed: ${parts.join(\"; \")}${suffix}`;\n}\n\nfunction annotateTrpcError(\n logger: Logger,\n path: string,\n type: TrpcProcedureType,\n error: unknown,\n includeHttp: boolean,\n): void {\n const status = trpcErrorHttpStatus(error);\n if (!logger.sealed) {\n logger.error(error, { status });\n const issues = zodValidationIssues(error);\n if (issues) {\n logger.set({ error: { message: shortValidationMessage(issues), issues } });\n }\n // On a batch, keep trpc.path null and record the failing procedure\n // separately — overwriting the batch path with the failer made group-bys\n // on trpc.path a half-truth.\n const trpcPatch = isBatchedRequest(logger)\n ? { failed_path: path, failed_type: type }\n : { path, type };\n const patch: Record = {\n trpc: trpcPatch,\n status,\n };\n if (includeHttp) {\n patch.http = { status };\n }\n logger.set(patch);\n }\n}\n\nfunction finalizeStandaloneRequest(\n logger: Logger,\n path: string,\n type: TrpcProcedureType,\n result: unknown,\n): void {\n if (logger.sealed) {\n return;\n }\n\n if (isFailedMiddlewareResult(result)) {\n annotateTrpcError(logger, path, type, result.error, false);\n } else {\n logger.set({\n trpc: { path, type },\n status: 200,\n });\n }\n\n logger.emit();\n scheduleFlush();\n}\n\nexport function amplioTrpcMiddleware() {\n return async (opts: {\n path: string;\n type: TrpcProcedureType;\n next: () => Promise;\n } & Record): Promise => {\n const { path, type, next } = opts;\n\n if (hasAmbientLogger()) {\n const logger = getLogger();\n annotateTrpcProcedure(logger, path, type);\n\n try {\n const result = await next();\n if (isFailedMiddlewareResult(result)) {\n annotateTrpcError(logger, path, type, result.error, true);\n }\n return result;\n } catch (error) {\n annotateTrpcError(logger, path, type, error, true);\n throw error;\n }\n }\n\n const requestLogger = createLogger({\n event: \"trpc.request\",\n \"@event\": \"trpc.request\",\n request_id: createRequestId(),\n transport: \"server-caller\",\n });\n\n return runWithLogger(requestLogger, async () => {\n annotateTrpcProcedure(requestLogger, path, type);\n\n try {\n const result = await next();\n finalizeStandaloneRequest(requestLogger, path, type, result);\n return result;\n } catch (error) {\n if (!requestLogger.sealed) {\n annotateTrpcError(requestLogger, path, type, error, false);\n requestLogger.emit();\n scheduleFlush();\n }\n throw error;\n }\n });\n };\n}\n" } ] }