From efafeccfb96c64889e2c788b02d3dd195ded6530 Mon Sep 17 00:00:00 2001 From: lyon Date: Sat, 20 Jun 2026 14:54:51 +0800 Subject: [PATCH] feat: attach cloud api otel trace headers --- internal/cloud/otel-trace.ts | 15 ++++++++ internal/cloud/server-health.test.ts | 35 +++++++++++++++++++ internal/cloud/server.ts | 35 +++++++++++++++++-- .../cloud/user-billing-integration.test.ts | 3 +- 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/internal/cloud/otel-trace.ts b/internal/cloud/otel-trace.ts index fe1da309..b3587718 100644 --- a/internal/cloud/otel-trace.ts +++ b/internal/cloud/otel-trace.ts @@ -61,6 +61,21 @@ export function authLoginOtelTraceContext(input = {}) { }; } +export function httpRequestOtelTraceContext(input = {}) { + const parsed = parseOtelTraceparent(input.traceparent); + const traceId = parsed?.traceId + ?? normalizeOtelTraceId(input.traceId) + ?? nonZeroHex(randomBytes(16).toString("hex"), ZERO_TRACE_ID); + const spanId = normalizeOtelSpanId(input.spanId) ?? newOtelSpanId(); + return { + traceId, + parentSpanId: parsed?.spanId ?? null, + spanId, + traceparent: `00-${traceId}-${spanId}-01`, + valuesPrinted: false + }; +} + export function newOtelSpanId() { return nonZeroHex(randomBytes(8).toString("hex"), ZERO_SPAN_ID); } diff --git a/internal/cloud/server-health.test.ts b/internal/cloud/server-health.test.ts index 241ba9ba..342d78dd 100644 --- a/internal/cloud/server-health.test.ts +++ b/internal/cloud/server-health.test.ts @@ -23,6 +23,41 @@ import { prepareFakeCodexHome } from "./server-test-helpers.ts"; +test("cloud api attaches OTel trace and request headers to normal and REST error responses", async () => { + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_PROVIDER: "test-disabled" + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const upstreamTraceparent = "00-11111111111111111111111111111111-2222222222222222-01"; + const ok = await fetch(`http://127.0.0.1:${port}/live`, { + headers: { + traceparent: upstreamTraceparent, + "x-request-id": "req_trace_context_test" + } + }); + assert.equal(ok.status, 200); + const okTraceparent = ok.headers.get("traceparent") ?? ""; + assert.match(okTraceparent, /^00-11111111111111111111111111111111-[0-9a-f]{16}-01$/u); + assert.notEqual(okTraceparent.split("-")[2], "2222222222222222"); + assert.equal(ok.headers.get("x-hwlab-otel-trace-id"), "11111111111111111111111111111111"); + assert.equal(ok.headers.get("x-request-id"), "req_trace_context_test"); + + const missing = await fetch(`http://127.0.0.1:${port}/route-not-implemented`); + assert.equal(missing.status, 404); + assert.match(missing.headers.get("traceparent") ?? "", /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/u); + assert.match(missing.headers.get("x-hwlab-otel-trace-id") ?? "", /^[0-9a-f]{32}$/u); + assert.match(missing.headers.get("x-request-id") ?? "", /^req_[0-9a-f-]{36}$/u); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api health reports DB env presence and live connection classification without leaking values", async () => { const originalUrl = process.env.HWLAB_CLOUD_DB_URL; const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 3b93b18b..ed1f11a3 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -1,5 +1,5 @@ /* - * SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0 + * SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-20-p0-error-diagnostics; PJ2026-010401 Web工作台 draft-2026-06-20-p0-error-diagnostics; PJ2026-01060505 Workbench Performance draft-2026-06-20-p0-error-diagnostics * 职责: Cloud API REST/SSE route dispatcher。Workbench 新资源路由应委托 read model / compat wrapper,不在 dispatcher 内写业务事实。 */ import { createServer } from "node:http"; @@ -73,6 +73,7 @@ import { handleWorkbenchReadModelHttp, handleWorkbenchRealtimeHttp } from "./ser import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts"; import { handleM3IoControlHttp } from "./server-m3-http.ts"; import { handleSkillsHttp } from "./server-skills-http.ts"; +import { httpRequestOtelTraceContext } from "./otel-trace.ts"; import { handleProviderProfileCatalogHttp, handleProviderProfilesHttp } from "./provider-profile-management.ts"; import { configureSkillRuntime, @@ -126,6 +127,8 @@ export function createCloudApiServer(options = {}) { logger: console }); const server = createServer(async (request, response) => { + const requestContext = buildHttpRequestContext(request); + attachHttpRequestContext(request, response, requestContext); const backendPerformance = backendPerformanceStore.beginHttpRequest(request, response); await withBackendPerformanceContext(backendPerformance, async () => { try { @@ -150,6 +153,29 @@ export function createCloudApiServer(options = {}) { return server; } +function buildHttpRequestContext(request) { + const otelContext = httpRequestOtelTraceContext({ + traceparent: getHeader(request, "traceparent"), + traceId: getHeader(request, "x-hwlab-otel-trace-id") + }); + return { + requestId: safeRequestId(getHeader(request, "x-request-id")) || `req_${randomUUID()}`, + traceId: otelContext.traceId, + parentSpanId: otelContext.parentSpanId, + spanId: otelContext.spanId, + traceparent: otelContext.traceparent, + valuesPrinted: false + }; +} + +function attachHttpRequestContext(request, response, context) { + request.hwlabHttpRequestContext = context; + if (response.headersSent || response.writableEnded) return; + response.setHeader("traceparent", context.traceparent); + response.setHeader("x-hwlab-otel-trace-id", context.traceId); + response.setHeader("x-request-id", context.requestId); +} + export function ensureCodeAgentRuntimeBase(env = process.env) { const codeAgentAdapter = String(env.HWLAB_CODE_AGENT_ADAPTER ?? env.HWLAB_CODE_AGENT_PROVIDER ?? "").trim().toLowerCase(); const useCodexStdioRuntime = !codeAgentAdapter || codeAgentAdapter === "codex-stdio"; @@ -1765,7 +1791,7 @@ async function persistGatewayDemoSession({ runtimeStore, session, request, env = } function sendRestError(request, response, statusCode, code, message, context = {}) { - const requestId = getHeader(request, "x-request-id") || "req_unassigned"; + const requestId = request.hwlabHttpRequestContext?.requestId || getHeader(request, "x-request-id") || "req_unassigned"; const environment = context.environment || runtimeEnvironment(context.env ?? process.env); sendJson(response, statusCode, { error: { @@ -1848,6 +1874,11 @@ function getHeader(request, name) { return value; } +function safeRequestId(value) { + const text = String(value ?? "").trim(); + return /^[A-Za-z0-9_.:-]{3,180}$/u.test(text) ? text : null; +} + function firstHeaderValue(request, name) { return getHeader(request, name); } diff --git a/internal/cloud/user-billing-integration.test.ts b/internal/cloud/user-billing-integration.test.ts index e2d53af6..e04b928a 100644 --- a/internal/cloud/user-billing-integration.test.ts +++ b/internal/cloud/user-billing-integration.test.ts @@ -1142,13 +1142,14 @@ test("cloud api exposes auth login OTel trace headers and forwards traceparent", const upstreamTraceparent = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"; const login = await fetch(`http://127.0.0.1:${port}/auth/login`, { method: "POST", - headers: { "content-type": "application/json", traceparent: upstreamTraceparent }, + headers: { "content-type": "application/json", traceparent: upstreamTraceparent, "x-request-id": "req_auth_otel_login" }, body: JSON.stringify({ username: "admin", password: "redacted-password" }) }); assert.equal(login.status, 200); const responseTraceparent = login.headers.get("traceparent") ?? ""; assert.match(responseTraceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/u); assert.equal(login.headers.get("x-hwlab-otel-trace-id"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + assert.equal(login.headers.get("x-request-id"), "req_auth_otel_login"); assert.equal(responseTraceparent.split("-")[1], "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); const loginCall = calls.find((call) => call.op === "login");