diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index b9b4b3eb..883f9a2e 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -270,6 +270,33 @@ function attachWorkbenchRealtimeOtelContext(request, fields = {}) { }; } +function attachWorkbenchReadModelDiagnosticOtel(request, fields = {}) { + const context = request?.hwlabHttpRequestContext; + if (!context) return; + const code = textValue(fields.code) || "workbench_read_model_not_found"; + const route = textValue(fields.route) || context.route || "/v1/workbench"; + context.route = route; + context.lastHttpError = { + code, + category: "workbench_read_model", + layer: "workbench.read_model" + }; + context.otelAttributes = { + ...(context.otelAttributes ?? {}), + "workbench.route": route, + "workbench.read_model.route": route, + "workbench.read_model.result": code, + "workbench.read_model.count": Number.isFinite(Number(fields.count)) ? Math.trunc(Number(fields.count)) : null, + "workbench.session_id": fields.sessionId ?? null, + "workbench.trace_id": fields.traceId ?? null, + "workbench.turn_id": fields.turnId ?? null, + traceId: fields.traceId ?? null, + "error.code": code, + "error.category": "workbench_read_model", + "error.layer": "workbench.read_model" + }; +} + function emitWorkbenchRealtimeAcceptedOtelSpan(request, env = process.env) { const context = request?.hwlabHttpRequestContext; if (!context?.traceId) return; @@ -1237,7 +1264,11 @@ async function handleWorkbenchSessionDetail(request, response, options, actor, s const result = await queryFactsByRouteId(readModel, sessionId, { families: WORKBENCH_SESSION_DETAIL_FAMILIES }); if (result.error) return sendJson(response, 503, workbenchProjectionStoreError(result.error)); const session = visibleFactSessions(result.facts, actor)[0] ?? null; - if (!session) return sendJson(response, 404, workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId })); + if (!session) { + const body = workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId }); + attachWorkbenchReadModelDiagnosticOtel(request, { route: "/v1/workbench/sessions/:id", code: "workbench_session_not_found", sessionId, count: result.count }); + return sendJson(response, 404, body); + } sendJson(response, 200, { ok: true, status: "found", @@ -1253,7 +1284,11 @@ async function handleWorkbenchMessagePage(request, response, url, options, actor const result = await queryFactsByRouteId(readModel, sessionId, { families: WORKBENCH_SESSION_MESSAGE_PAGE_FAMILIES }); if (result.error) return sendJson(response, 503, workbenchProjectionStoreError(result.error)); const session = visibleFactSessions(result.facts, actor)[0] ?? null; - if (!session) return sendJson(response, 404, workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId })); + if (!session) { + const body = workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId }); + attachWorkbenchReadModelDiagnosticOtel(request, { route: "/v1/workbench/sessions/:id/messages", code: "workbench_session_not_found", sessionId, count: result.count }); + return sendJson(response, 404, body); + } const messages = factMessagesForSession(session, result.facts); const limit = boundedLimit(url.searchParams.get("limit")); const offset = cursorOffset(url.searchParams.get("cursor") ?? url.searchParams.get("after")); @@ -1298,6 +1333,7 @@ async function handleWorkbenchTurnSnapshot(request, response, url, options, acto const found = Boolean(session || turn); if (!found) { const body = workbenchError("workbench_turn_not_found", "Workbench turn is not visible to the current actor.", { turnId, traceId }); + attachWorkbenchReadModelDiagnosticOtel(request, { route: "/v1/workbench/turns/:id", code: "workbench_turn_not_found", traceId, turnId, count: result.count }); recordWorkbenchTurnReadMetric(options, url, 404, body, startedAt); return sendJson(response, 404, body); } @@ -1351,8 +1387,10 @@ async function handleWorkbenchTraceEventPage(request, response, url, options, ac if (contextSession || contextTurn) { const projection = factProjectionForTrace(context.facts, traceId); const blocker = traceEventsReadModelBlocker("workbench_trace_metadata_missing", "Workbench trace metadata is missing from the trace events read model.", { traceId, projection, session: contextSession, turn: contextTurn, route: url.pathname }); + attachWorkbenchReadModelDiagnosticOtel(request, { route: "/v1/workbench/traces/:id/events", code: "workbench_trace_metadata_missing", sessionId: factSessionId(contextSession), traceId, count: context.count }); return sendJson(response, 404, workbenchTraceEventsReadModelError(blocker, { traceId, projection, session: contextSession, turn: contextTurn })); } + attachWorkbenchReadModelDiagnosticOtel(request, { route: "/v1/workbench/traces/:id/events", code: "workbench_trace_not_found", traceId, count: context.count }); return sendJson(response, 404, workbenchError("workbench_trace_not_found", "Workbench trace is not visible to the current actor.", { traceId })); } const projection = factProjectionForTrace(metadataFacts, traceId);