/* RFC-1172: emission helper for the closed `werkstatt.agent.traffic` event. Route boundaries create one {@link TrafficRecorder} per request, handlers record the fields they know (operation, capability, problemKind, duplicate, mcpProtocolVersion, observedIdentity) via optional chaining, and the boundary calls {@link emitAgentTraffic} exactly once after the gate call resolves — status derived from the response, durationMs measured around it. Emission is fire-and-forget: a missing port, missing operation, or a throwing sink drops the event silently — observability never breaks requests. Do not emit request/response payloads — the closed schema (werkstatt-shared/agent/telemetry) rejects them. Do not correlate or trace — RFC-1116's tracing non-goal stands. RFC-1172: initial emission helper — TrafficRecorder + boundary emit + status derivation. RFC-1172: closed agent.traffic telemetry — ports.telemetry sink, TrafficRecorder + boundary emit via withTrafficEmit */ import { agentTrafficEventSchema, type AgentTrafficEvent } from "@warpgogol/werkstatt-shared/agent"; import type { AgentGatePorts } from "./ports.ts"; /** * Per-request context handlers fill on their terminal paths. The boundary * creates it, hands it into the gate call, and reads it back for the single * emission. Every field is optional — handlers set only what they know. */ export interface TrafficRecorder { /** * Closed operation token. Route-determinable for single-operation routes * (search/ask/health/action.call); handlers override when the request picks * the operation (MCP tools/list vs tools/call vs other, A2A ops, method * rejections). Emission is skipped when still unset at the boundary — * never emit a fake value. */ operation?: AgentTrafficEvent["operation"]; /** Action/tool/method id — action id, tool name, or raw JSON-RPC method for `other`. */ capability?: string; /** RFC 9457 `type` token when the terminal path is a problem. */ problemKind?: string; /** RFC-1112: idempotent replay served from a stored receipt. */ duplicate?: boolean; /** Negotiated MCP protocol version (RFC-1113) — MCP requests only. */ mcpProtocolVersion?: string; /** RFC-0291 identity headers, exactly as observed — never trusted. */ observedIdentity?: Record; /** * Handlers set this when the response status code does not express the * semantic outcome — JSON-RPC errors ride HTTP 200 (failure vs success), * method-not-found is a rejected operation inside a 200 envelope. */ statusOverride?: AgentTrafficEvent["status"]; } /** * Map the terminal response to the closed status enum: 2xx → success, * 429 → rate-limited, other 4xx → rejected (pre-handler gate refusal), * anything else → failure. */ export function deriveTrafficStatus(response: Response): AgentTrafficEvent["status"] { if (response.status >= 200 && response.status < 300) return "success"; if (response.status === 429) return "rate-limited"; if (response.status >= 400 && response.status < 500) return "rejected"; return "failure"; } /** * Extract the RFC 9457 `type` token from a problem+json response without * consuming the response (clone-read). Returns undefined for non-problem * responses or unreadable bodies. */ async function readProblemKind(response: Response): Promise { if (!response.headers.get("content-type")?.includes("problem+json")) return undefined; try { const body = (await response.clone().json()) as { type?: unknown }; return typeof body.type === "string" ? body.type : undefined; } catch { return undefined; } } /** * Emit exactly one closed event per request. Fire-and-forget: never throws — * a missing port, a missing operation, or a sink error all degrade to a * silent drop (traffic handling outranks observability). Async so the * problem-kind clone-read can run; callers may `await` (tests, adapters) or * fire and forget. */ export async function emitAgentTraffic( ports: Pick, input: { systemId: string; protocol: AgentTrafficEvent["protocol"]; response: Response; traffic: TrafficRecorder; durationMs: number; }, ): Promise { try { if (!ports.telemetry || !input.traffic.operation) return; const event = agentTrafficEventSchema.parse({ type: "werkstatt.agent.traffic", timestamp: ports.now().toISOString(), systemId: input.systemId, protocol: input.protocol, operation: input.traffic.operation, capability: input.traffic.capability, status: input.traffic.statusOverride ?? deriveTrafficStatus(input.response), problemKind: input.traffic.problemKind ?? (await readProblemKind(input.response)), duplicate: input.traffic.duplicate, mcpProtocolVersion: input.traffic.mcpProtocolVersion, observedIdentity: input.traffic.observedIdentity, durationMs: Math.max(0, Math.round(input.durationMs)), }); ports.telemetry(event); } catch { // Silent drop — telemetry never breaks the request path (RFC-1172). } } /** * Production sink — one structured JSON line per event on stdout, captured * by Workers logs. Wired in `astro.ts` buildPorts; tests inject collectors. */ export function workerLogTelemetry(event: AgentTrafficEvent): void { console.log(JSON.stringify(event)); }