From a5b47d5d8db56317ff999af8f45f9297b856bcd9 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:44:22 +0800 Subject: [PATCH] fix: expose workbench projection failures in otel (#1824) --- internal/cloud/server-workbench-http.ts | 6 +++- internal/cloud/workbench-facts-store.ts | 35 +++++++++++++++++++ .../workbench/ConversationPanel.vue | 12 ++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 9ad3a408..bf6b53fd 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -1095,7 +1095,11 @@ async function handleWorkbenchTurnSnapshot(response, url, options, actor, rawTur } const readModel = createWorkbenchReadModel(options, actor); const result = await readModel.queryFacts({ traceId, limit: MAX_PAGE_LIMIT }); - if (result.error) return sendJson(response, 503, workbenchProjectionStoreError(result.error)); + if (result.error) { + const body = workbenchProjectionStoreError(result.error); + recordWorkbenchTurnReadMetric(options, url, 503, body, startedAt); + return sendJson(response, 503, body); + } const session = visibleFactSessions(result.facts, actor).find((item) => item.lastTraceId === traceId) ?? visibleFactSessions(result.facts, actor)[0] ?? null; const turn = factTurnForTrace(result.facts, traceId, turnId); const found = Boolean(session || turn); diff --git a/internal/cloud/workbench-facts-store.ts b/internal/cloud/workbench-facts-store.ts index ba6bbde8..04e5c878 100644 --- a/internal/cloud/workbench-facts-store.ts +++ b/internal/cloud/workbench-facts-store.ts @@ -3,6 +3,7 @@ * 职责: Workbench durable facts store adapter。统一读取 session/message/turn/trace projection facts,不在 GET 中推进 AgentRun facts。 */ import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; +import { emitCodeAgentOtelSpan } from "./otel-trace.ts"; import { safeConversationId, safeSessionId, safeTraceId } from "./server-http-utils.ts"; import { durableTraceStatus, normalizeWorkbenchStatus, TERMINAL_STATUSES, traceTerminalEvidence } from "./workbench-turn-projection.ts"; @@ -46,6 +47,7 @@ export function createWorkbenchFactsStore(options = {}, actor = null) { retryExhausted: finalAttempt && retryable }); if (finalAttempt) { + emitWorkbenchFactsQueryFailureOtel(params, annotatedError); logWorkbenchFactsQueryFailure(options.logger ?? console, params, annotatedError); return { facts: emptyWorkbenchFacts(), @@ -208,6 +210,39 @@ function logWorkbenchFactsQueryFailure(logger, params = {}, error = null) { } catch {} } +function emitWorkbenchFactsQueryFailureOtel(params = {}, error = null) { + const data = objectValue(error?.data) ?? {}; + const families = Array.isArray(params.families) ? params.families : []; + const traceId = safeTraceId(params.traceId) ?? "trc_unassigned"; + void emitCodeAgentOtelSpan("workbench.projection_store.query.failed", traceId, process.env, { + status: "error", + error, + attributes: { + "workbench.projection_store.error": true, + "workbench.projection_store.retryable": data.retryable === true, + "workbench.projection_store.transient": data.transient === true, + "workbench.projection_store.retry_attempt": otelNonNegativeIntegerOrNull(data.retryAttempt ?? data.retryAttempts), + "workbench.projection_store.retry_max": otelNonNegativeIntegerOrNull(data.retryMax), + "workbench.projection_store.retry_exhausted": data.retryExhausted === true, + "workbench.projection_store.retry_after_ms": otelNonNegativeIntegerOrNull(data.retryAfterMs), + "workbench.facts.family_count": families.length, + "workbench.facts.families": families.join(","), + "workbench.facts.has_session_id": Boolean(params.sessionId), + "workbench.facts.has_trace_id": Boolean(params.traceId), + "workbench.facts.has_owner_user_id": Boolean(params.ownerUserId), + "error.code": error?.code ?? null, + "error.category": "projection_store_unavailable", + "error.layer": "runtime-store", + valuesRedacted: true + } + }).catch(() => undefined); +} + +function otelNonNegativeIntegerOrNull(value) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : null; +} + async function durableTraceSnapshot(runtimeStore, traceId) { if (!runtimeStore || typeof runtimeStore.queryAgentTraceEvents !== "function") return null; const safeId = safeTraceId(traceId); diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index b88b75a0..369d63b4 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -307,7 +307,17 @@ function formatDuration(ms: number): string {