From 1b4a465ea1cbcdbf40e915253ac13e97e4a59ffd Mon Sep 17 00:00:00 2001 From: Yue Fei Date: Tue, 28 Jul 2026 10:15:05 -0700 Subject: [PATCH] feat(gateway): accept WebSocket request trace context (#113189) * feat(gateway): accept WebSocket request trace context * test(gateway): prove WebSocket trace isolation * test(gateway): simplify traced response match * ci: allow iOS screenshot validation to finish * fix(gateway): keep traced request failures scoped * test(ci): align iOS screenshot timeout contract * test(ui): reset config route location * ci: scope iOS screenshots to native changes --------- Co-authored-by: Peter Steinberger --- .github/workflows/ci.yml | 6 +- .../openclaw/app/gateway/GatewayProtocol.kt | 1 + .../OpenClawProtocol/GatewayModels.swift | 6 +- docs/docs_map.md | 1 + docs/gateway/opentelemetry.md | 33 ++ docs/gateway/protocol.md | 11 +- packages/gateway-protocol/src/index.test.ts | 18 + .../gateway-protocol/src/schema/frames.ts | 1 + ...henticated-request-dispatch.server.test.ts | 358 ++++++++++++++++++ .../authenticated-request-dispatch.ts | 45 ++- ...essage-handler.post-connect-health.test.ts | 63 ++- src/gateway/test-helpers.server.ts | 2 + .../ios-release-fastlane-gates.test.ts | 5 +- 13 files changed, 526 insertions(+), 24 deletions(-) create mode 100644 src/gateway/server/ws-connection/authenticated-request-dispatch.server.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 764315c62aa5..b19bd972de34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3171,7 +3171,7 @@ jobs: needs: [preflight] if: needs.preflight.outputs.run_ios_build == 'true' runs-on: ${{ (github.event_name == 'workflow_dispatch' || github.run_attempt > 1) && 'macos-26' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-12vcpu-macos-26' || 'macos-26') }} - timeout-minutes: 75 + timeout-minutes: 150 env: HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }} steps: @@ -3316,11 +3316,11 @@ jobs: retention-days: 14 - name: Capture iOS release screenshots - if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && env.HISTORICAL_TARGET != 'true' }} + if: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && needs.preflight.outputs.run_macos == 'true')) && env.HISTORICAL_TARGET != 'true' }} run: pnpm ios:screenshots - name: Upload iOS release screenshot evidence - if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && env.HISTORICAL_TARGET != 'true' }} + if: ${{ always() && (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && needs.preflight.outputs.run_macos == 'true')) && env.HISTORICAL_TARGET != 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ios-release-screenshots-${{ needs.preflight.outputs.checkout_revision }} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 6128cbc6420e..631ce7d58237 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -23,6 +23,7 @@ data class GatewayRequestFrame( val id: String, val method: String, val params: JsonElement? = null, + val traceparent: String? = null, ) @Serializable diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index a9ed28a7be20..e885f8243312 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1179,17 +1179,20 @@ public struct RequestFrame: Codable, Sendable { public let id: String public let method: String public let params: AnyCodable? + public let traceparent: String? public init( type: String, id: String, method: String, - params: AnyCodable? = nil) + params: AnyCodable? = nil, + traceparent: String? = nil) { self.type = type self.id = id self.method = method self.params = params + self.traceparent = traceparent } private enum CodingKeys: String, CodingKey { @@ -1197,6 +1200,7 @@ public struct RequestFrame: Codable, Sendable { case id case method case params + case traceparent } } diff --git a/docs/docs_map.md b/docs/docs_map.md index df838cb96063..84f338886281 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -3851,6 +3851,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Signals exported - H2: Configuration reference - H3: Environment variables + - H2: Continue an upstream WebSocket trace - H2: Privacy and content capture - H2: Sampling and flushing - H3: Model-call observation units diff --git a/docs/gateway/opentelemetry.md b/docs/gateway/opentelemetry.md index e40015ecb60e..02a441c6c595 100644 --- a/docs/gateway/opentelemetry.md +++ b/docs/gateway/opentelemetry.md @@ -114,6 +114,39 @@ stdout, or `both` for both. | `OTEL_SEMCONV_STABILITY_OPT_IN` | Set to `gen_ai_latest_experimental` to emit the latest GenAI inference span shape: `{gen_ai.operation.name} {gen_ai.request.model}` span names, `CLIENT` span kind, and `gen_ai.provider.name` instead of the legacy `gen_ai.system`. GenAI metrics always use bounded, low-cardinality attributes regardless. | | `OPENCLAW_OTEL_PRELOADED` | Set to `1` when another preload or host process already registered the global OpenTelemetry SDK. The plugin then skips its own NodeSDK lifecycle but still wires diagnostic listeners and honors `traces`/`metrics`/`logs`. | +## Continue an upstream WebSocket trace + +An authenticated Gateway WebSocket client can attach a W3C `traceparent` to +each request frame: + +```json +{ + "type": "req", + "id": "eval-item-42", + "method": "agent", + "params": {}, + "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" +} +``` + +The Gateway creates a child request context that preserves the upstream trace +ID and sampling flags. Agent, harness, model-call, and provider spans created +inside the request remain on that trace. This allows a local experiment runner +to create one Langfuse/OpenTelemetry trace per dataset item and correlate the +corresponding OpenClaw execution. + +Trace context is request-scoped, not connection-scoped. On a long-lived +WebSocket, generate or inject the appropriate `traceparent` independently for +every RPC. Concurrent requests remain isolated even when their work +interleaves. + +The field is accepted only after the existing Gateway authentication handshake +and does not affect authentication or method authorization. A `traceparent` on +the initial `connect` frame is ignored. Missing or syntactically malformed +values within the 128-character field limit silently fall back to a fresh +request trace; longer values make the request frame invalid. `tracestate` and +`baggage` are not accepted by the Gateway WebSocket protocol. + ## Privacy and content capture Raw model/tool content is **not** exported by default. Spans carry bounded diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index f8e69d859176..2993dce05f60 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -45,10 +45,19 @@ that supervise the Gateway as a child process, see Frame shapes: -- Request: `{type:"req", id, method, params}` +- Request: `{type:"req", id, method, params, traceparent?}` - Response: `{type:"res", id, ok, payload|error}` - Event: `{type:"event", event, payload, seq?, stateVersion?}` +After authentication, a client may include a W3C `traceparent` string on each +request frame. The Gateway continues a valid value as a child trace context for +that request. Missing or syntactically malformed values within the +128-character field limit keep the default fresh request trace and do not fail +the RPC; longer values make the request frame invalid. The initial `connect` +request never establishes trace context for later frames. Use a separate +`traceparent` for each logical request on a long-lived connection; do not treat +the WebSocket itself as one trace. + Response errors use `{ code, message, details?, retryable?, retryAfterMs? }`. Clients should branch on `code` and `details.code`; `message` remains human-readable and can change except where a compatibility note says otherwise. Method-level diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index 2f4baebe7791..fb588d670804 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -89,6 +89,24 @@ describe("protocol export registries", () => { }); describe("lazy protocol validators", () => { + it("accepts bounded request-frame trace context metadata", () => { + const request = { + type: "req", + id: "request-1", + method: "status.summary", + params: {}, + }; + + expect(protocol.validateRequestFrame(request)).toBe(true); + expect( + protocol.validateRequestFrame({ + ...request, + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + }), + ).toBe(true); + expect(protocol.validateRequestFrame({ ...request, traceparent: "x".repeat(129) })).toBe(false); + }); + it("validates through exported lazy validators", () => { expect(validateCommandsListParams({})).toBe(true); expect(validateCommandsListParams({ includeArgs: true })).toBe(true); diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index 2d0b10a09945..3ff0cd70baae 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -158,6 +158,7 @@ export const RequestFrameSchema = closedObject({ id: NonEmptyString, method: NonEmptyString, params: Type.Optional(Type.Unknown()), + traceparent: Type.Optional(Type.String({ maxLength: 128 })), }); /** Server response frame envelope paired with a prior request id. */ diff --git a/src/gateway/server/ws-connection/authenticated-request-dispatch.server.test.ts b/src/gateway/server/ws-connection/authenticated-request-dispatch.server.test.ts new file mode 100644 index 000000000000..a820ce79929e --- /dev/null +++ b/src/gateway/server/ws-connection/authenticated-request-dispatch.server.test.ts @@ -0,0 +1,358 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; +import { + createDiagnosticTraceContext, + getActiveDiagnosticTraceContext, + runWithDiagnosticTraceContext, + type DiagnosticTraceContext, +} from "../../../infra/diagnostic-trace-context.js"; +import { createEmptyPluginRegistry } from "../../../plugins/registry-empty.js"; +import { + connectOk, + getFreePort, + installGatewayTestHooks, + onceMessage, + startGatewayServer, + trackConnectChallengeNonce, +} from "../../test-helpers.js"; +import { + resetTestPluginRegistry, + setTestPluginRegistry, +} from "../../test-helpers.plugin-registry.js"; +import type { GatewayWsClient } from "../ws-types.js"; +import { createGatewayAuthenticatedRequestDispatcher } from "./authenticated-request-dispatch.js"; +import type { GatewayWsMessageHandlerParams } from "./message-handler-types.js"; + +const TRACEPARENTS = { + first: "00-11111111111111111111111111111111-1111111111111111-01", + second: "00-22222222222222222222222222222222-2222222222222222-00", +} as const; + +installGatewayTestHooks({ scope: "suite" }); + +function createClient(): GatewayWsClient { + return { + socket: {} as WebSocket, + connect: { + minProtocol: 1, + maxProtocol: 1, + client: { + id: "gateway-client", + version: "dev", + platform: "test", + mode: "backend", + }, + role: "operator", + scopes: ["operator.admin"], + }, + connId: "conn-trace-test", + usesSharedGatewayAuth: false, + }; +} + +function createDispatcher( + handler: NonNullable, +) { + const send = vi.fn(); + const logGateway = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const dispatcher = createGatewayAuthenticatedRequestDispatcher({ + handler: { + connId: "conn-trace-test", + extraHandlers: { "test.trace": handler }, + buildRequestContext: () => ({}) as never, + send, + close: vi.fn(), + isClosed: () => false, + setCloseCause: vi.fn(), + logGateway, + } as unknown as GatewayWsMessageHandlerParams, + isWebchatConnect: () => false, + }); + return { dispatcher, logGateway, send }; +} + +async function dispatchInFreshMessageScope( + dispatcher: ReturnType["dispatcher"], + client: GatewayWsClient, + id: string, + traceparent?: string, +): Promise { + await runWithDiagnosticTraceContext(createDiagnosticTraceContext(), () => + dispatcher.dispatch( + { + type: "req", + id, + method: "test.trace", + params: {}, + ...(traceparent ? { traceparent } : {}), + }, + client, + ), + ); +} + +async function openAuthenticatedTraceSocket(params: { + port: number; + token: string; + connectTraceparent: string; +}): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${params.port}`); + trackConnectChallengeNonce(ws); + await new Promise((resolve, reject) => { + const onOpen = () => { + ws.off("error", onError); + resolve(); + }; + const onError = (error: Error) => { + ws.off("open", onOpen); + reject(error); + }; + ws.once("open", onOpen); + ws.once("error", onError); + }); + try { + await connectOk(ws, { + token: params.token, + traceparent: params.connectTraceparent, + }); + return ws; + } catch (error) { + ws.terminate(); + throw error; + } +} + +async function sendTraceRequest( + ws: WebSocket, + id: string, + traceparent?: string, +): Promise<{ ok: boolean }> { + const response = onceMessage<{ type: "res"; id: string; ok: boolean }>( + ws, + (value) => value.type === "res" && value.id === id, + ); + ws.send( + JSON.stringify({ + type: "req", + id, + method: "test.trace", + params: {}, + ...(traceparent ? { traceparent } : {}), + }), + ); + return await response; +} + +describe("authenticated WebSocket request trace dispatch", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("continues a valid upstream trace as a child context", async () => { + let observed: DiagnosticTraceContext | undefined; + const { dispatcher } = createDispatcher(() => { + observed = getActiveDiagnosticTraceContext(); + }); + + await dispatchInFreshMessageScope(dispatcher, createClient(), "first", TRACEPARENTS.first); + await vi.waitFor(() => { + expect(observed).toBeDefined(); + }); + + expect(observed).toMatchObject({ + traceId: "11111111111111111111111111111111", + parentSpanId: "1111111111111111", + traceFlags: "01", + }); + expect(observed?.spanId).not.toBe("1111111111111111"); + }); + + it("keeps handler failure logging and responses inside the request trace", async () => { + let loggedContext: DiagnosticTraceContext | undefined; + let responseContext: DiagnosticTraceContext | undefined; + const { dispatcher, logGateway, send } = createDispatcher(async () => { + throw new Error("expected trace failure"); + }); + logGateway.error.mockImplementation(() => { + loggedContext = getActiveDiagnosticTraceContext(); + }); + send.mockImplementation(() => { + responseContext = getActiveDiagnosticTraceContext(); + }); + + await dispatchInFreshMessageScope(dispatcher, createClient(), "failure", TRACEPARENTS.first); + await vi.waitFor(() => { + expect(logGateway.error).toHaveBeenCalled(); + expect(send).toHaveBeenCalled(); + }); + + expect(loggedContext).toMatchObject({ + traceId: "11111111111111111111111111111111", + parentSpanId: "1111111111111111", + traceFlags: "01", + }); + expect(responseContext).toEqual(loggedContext); + }); + + it("retains fresh roots for missing and malformed traceparent values", async () => { + const observed = new Map(); + const { dispatcher } = createDispatcher(({ req }) => { + observed.set(req.id, getActiveDiagnosticTraceContext()); + }); + const client = createClient(); + + await dispatchInFreshMessageScope(dispatcher, client, "missing"); + await vi.waitFor(() => { + expect(observed.has("missing")).toBe(true); + }); + await dispatchInFreshMessageScope( + dispatcher, + client, + "malformed", + "00-11111111111111111111111111111111-1111111111111111-zz", + ); + await vi.waitFor(() => { + expect(observed.has("malformed")).toBe(true); + }); + + const missing = observed.get("missing"); + const malformed = observed.get("malformed"); + expect(missing).toBeDefined(); + expect(malformed).toBeDefined(); + expect(missing?.traceId).not.toBe("11111111111111111111111111111111"); + expect(malformed?.traceId).not.toBe("11111111111111111111111111111111"); + expect(missing?.traceId).not.toBe(malformed?.traceId); + }); + + it("isolates concurrent request contexts on one connection", async () => { + let releaseRequests: (() => void) | undefined; + const requestBarrier = new Promise((resolve) => { + releaseRequests = resolve; + }); + const observed = new Map< + string, + { before: DiagnosticTraceContext | undefined; after?: DiagnosticTraceContext } + >(); + const { dispatcher } = createDispatcher(async ({ req }) => { + const observation: { + before: DiagnosticTraceContext | undefined; + after?: DiagnosticTraceContext; + } = { before: getActiveDiagnosticTraceContext() }; + observed.set(req.id, observation); + await requestBarrier; + observation.after = getActiveDiagnosticTraceContext(); + }); + const client = createClient(); + + await Promise.all([ + dispatchInFreshMessageScope(dispatcher, client, "first", TRACEPARENTS.first), + dispatchInFreshMessageScope(dispatcher, client, "second", TRACEPARENTS.second), + ]); + await vi.waitFor(() => { + expect(observed.size).toBe(2); + }); + releaseRequests?.(); + await vi.waitFor(() => { + expect([...observed.values()].every((entry) => entry.after)).toBe(true); + }); + + expect(observed.get("first")?.before?.traceId).toBe("11111111111111111111111111111111"); + expect(observed.get("second")?.before?.traceId).toBe("22222222222222222222222222222222"); + expect(observed.get("first")?.after).toEqual(observed.get("first")?.before); + expect(observed.get("second")?.after).toEqual(observed.get("second")?.before); + }); + + it("preserves request isolation through a real authenticated WebSocket session", async () => { + const observed = new Map< + string, + { before: DiagnosticTraceContext | undefined; after?: DiagnosticTraceContext } + >(); + let requestBarrier: Promise | undefined; + let releaseRequests: (() => void) | undefined; + const registry = createEmptyPluginRegistry(); + registry.gatewayHandlers["test.trace"] = async ({ req, respond }) => { + const observation: { + before: DiagnosticTraceContext | undefined; + after?: DiagnosticTraceContext; + } = { before: getActiveDiagnosticTraceContext() }; + observed.set(req.id, observation); + await requestBarrier; + observation.after = getActiveDiagnosticTraceContext(); + respond(true, { traced: true }); + }; + setTestPluginRegistry(registry); + + const token = "gateway-request-trace-test-token"; + const port = await getFreePort(); + const server = await startGatewayServer(port, { + auth: { mode: "token", token }, + bind: "loopback", + controlUiEnabled: false, + }); + let ws: WebSocket | undefined; + try { + ws = await openAuthenticatedTraceSocket({ + port, + token, + connectTraceparent: TRACEPARENTS.first, + }); + + await expect(sendTraceRequest(ws, "untraced-after-connect")).resolves.toMatchObject({ + ok: true, + }); + await expect( + sendTraceRequest( + ws, + "malformed", + "00-11111111111111111111111111111111-1111111111111111-zz", + ), + ).resolves.toMatchObject({ ok: true }); + const afterConnect = observed.get("untraced-after-connect")?.before; + const malformed = observed.get("malformed")?.before; + expect(afterConnect).toBeDefined(); + expect(malformed).toBeDefined(); + expect(afterConnect?.traceId).not.toBe("11111111111111111111111111111111"); + expect(malformed?.traceId).not.toBe("11111111111111111111111111111111"); + expect(malformed?.traceId).not.toBe(afterConnect?.traceId); + + requestBarrier = new Promise((resolve) => { + releaseRequests = resolve; + }); + const first = sendTraceRequest(ws, "concurrent-first", TRACEPARENTS.first); + const second = sendTraceRequest(ws, "concurrent-second", TRACEPARENTS.second); + await vi.waitFor(() => { + expect(observed.has("concurrent-first")).toBe(true); + expect(observed.has("concurrent-second")).toBe(true); + }); + releaseRequests?.(); + await expect(Promise.all([first, second])).resolves.toMatchObject([ + { ok: true }, + { ok: true }, + ]); + + const firstObservation = observed.get("concurrent-first"); + const secondObservation = observed.get("concurrent-second"); + expect(firstObservation?.before).toMatchObject({ + traceId: "11111111111111111111111111111111", + parentSpanId: "1111111111111111", + traceFlags: "01", + }); + expect(secondObservation?.before).toMatchObject({ + traceId: "22222222222222222222222222222222", + parentSpanId: "2222222222222222", + traceFlags: "00", + }); + expect(firstObservation?.after).toEqual(firstObservation?.before); + expect(secondObservation?.after).toEqual(secondObservation?.before); + } finally { + ws?.terminate(); + await server.close(); + resetTestPluginRegistry(); + } + }); +}); diff --git a/src/gateway/server/ws-connection/authenticated-request-dispatch.ts b/src/gateway/server/ws-connection/authenticated-request-dispatch.ts index 66ac0b5d8343..914364508dc7 100644 --- a/src/gateway/server/ws-connection/authenticated-request-dispatch.ts +++ b/src/gateway/server/ws-connection/authenticated-request-dispatch.ts @@ -5,6 +5,11 @@ import { formatValidationErrors, validateRequestFrame, } from "../../../../packages/gateway-protocol/src/index.js"; +import { + createChildDiagnosticTraceContext, + parseDiagnosticTraceparent, + runWithDiagnosticTraceContext, +} from "../../../infra/diagnostic-trace-context.js"; import { formatForLog, logWs } from "../../ws-log.js"; import type { GatewayWsClient } from "../ws-types.js"; import type { GatewayWsMessageHandlerParams } from "./message-handler-types.js"; @@ -137,21 +142,31 @@ export function createGatewayAuthenticatedRequestDispatcher(params: { }); }; - const requestDispatch = (async () => { - const { handleGatewayRequest } = await import("../../server-methods.js"); - await handleGatewayRequest({ - req, - respond, - client, - isWebchatConnect: params.isWebchatConnect, - extraHandlers, - methodRegistry: getMethodRegistry?.(), - context: buildRequestContext(), - }); - })().catch((err: unknown) => { - logGateway.error(`request handler failed: ${formatForLog(err)}`); - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); - }); + const executeRequest = async () => { + try { + const { handleGatewayRequest } = await import("../../server-methods.js"); + await handleGatewayRequest({ + req, + respond, + client, + isWebchatConnect: params.isWebchatConnect, + extraHandlers, + methodRegistry: getMethodRegistry?.(), + context: buildRequestContext(), + }); + } catch (err) { + // Failure diagnostics and responses belong to the same request trace as the handler. + logGateway.error(`request handler failed: ${formatForLog(err)}`); + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); + } + }; + const upstreamTrace = parseDiagnosticTraceparent(req.traceparent); + const requestDispatch = upstreamTrace + ? runWithDiagnosticTraceContext( + createChildDiagnosticTraceContext(upstreamTrace), + executeRequest, + ) + : executeRequest(); if (DEVICE_CREDENTIAL_INVALIDATING_METHODS.has(req.method)) { const barrier = requestDispatch.finally(() => { if (deviceCredentialMutationBarrier === barrier) { diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index eee08f93c826..1b5c41e4b17a 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -10,6 +10,10 @@ import { resetDiagnosticEventsForTest, type DiagnosticSecurityEvent, } from "../../../infra/diagnostic-events.js"; +import { + getActiveDiagnosticTraceContext, + type DiagnosticTraceContext, +} from "../../../infra/diagnostic-trace-context.js"; import { setAvatar } from "../../../state/user-profiles.js"; import { withOpenClawTestState } from "../../../test-utils/openclaw-test-state.js"; import { mintAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js"; @@ -282,23 +286,30 @@ function attachGatewayHarness(options: { logWsControl, send, socketSend, - sendRequest: (id: string, method: string, params: Record = {}) => { + sendRequest: ( + id: string, + method: string, + params: Record = {}, + traceparent?: string, + ) => { sendMessage( JSON.stringify({ type: "req", id, method, params, + ...(traceparent ? { traceparent } : {}), }), ); }, - sendConnect: (id: string, params: Record) => { + sendConnect: (id: string, params: Record, traceparent?: string) => { sendMessage( JSON.stringify({ type: "req", id, method: "connect", params, + ...(traceparent ? { traceparent } : {}), }), ); }, @@ -308,6 +319,54 @@ function attachGatewayHarness(options: { }; } +describe("WebSocket request trace context", () => { + const upstreamTraceId = "4bf92f3577b34da6a3ce929d0e0e4736"; + const upstreamSpanId = "00f067aa0ba902b7"; + const upstreamTraceparent = `00-${upstreamTraceId}-${upstreamSpanId}-01`; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not carry connect-frame trace context into later requests", async () => { + let observed: DiagnosticTraceContext | undefined; + vi.mocked(handleGatewayRequest).mockImplementation(async () => { + observed = getActiveDiagnosticTraceContext(); + }); + const harness = attachGatewayHarness({ + connId: "conn-connect-trace", + connectNonce: "nonce-connect-trace", + }); + + harness.sendConnect( + "connect-1", + { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "gateway-client", + version: "dev", + platform: "test", + mode: "backend", + }, + role: "operator", + caps: [], + }, + upstreamTraceparent, + ); + await waitForFast(() => { + expect(harness.client).not.toBeNull(); + }); + + harness.sendRequest("untraced-1", "status.summary"); + + await waitForFast(() => { + expect(observed).toBeDefined(); + }); + expect(observed?.traceId).not.toBe(upstreamTraceId); + }); +}); + function connectTrustedProxyUser(connId: string) { loadConfigMock.mockImplementationOnce(() => ({ gateway: { diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index c561abda5604..67eadcb80052 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -1004,6 +1004,7 @@ type ConnectReqOptions = { prePairDevice?: boolean; browserOrigin?: string; timeoutMs?: number; + traceparent?: string; }; function shouldPrePairTestDevice(params: { @@ -1193,6 +1194,7 @@ export async function connectReq( type: "req", id, method: "connect", + ...(opts?.traceparent ? { traceparent: opts.traceparent } : {}), params: { minProtocol: opts?.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts?.maxProtocol ?? PROTOCOL_VERSION, diff --git a/test/scripts/ios-release-fastlane-gates.test.ts b/test/scripts/ios-release-fastlane-gates.test.ts index b6c43034ae7a..a565906e51aa 100644 --- a/test/scripts/ios-release-fastlane-gates.test.ts +++ b/test/scripts/ios-release-fastlane-gates.test.ts @@ -310,16 +310,17 @@ describe("iOS Fastlane release upload gates", () => { ); }); - it("runs the exact screenshot lane during manual and full release CI", () => { + it("runs the exact screenshot lane during native Apple, manual, and full release CI", () => { const workflow = readFileSync(ciWorkflowPath, "utf8"); const iosJobStart = workflow.indexOf("\n ios-build:\n"); const iosJobEnd = workflow.indexOf("\n android:\n", iosJobStart); const iosJob = workflow.slice(iosJobStart, iosJobEnd); - expect(iosJob).toContain("timeout-minutes: 75"); + expect(iosJob).toContain("timeout-minutes: 150"); expect(iosJob).toContain("Capture iOS release screenshots"); expect(iosJob).toContain("github.event_name == 'workflow_dispatch'"); expect(iosJob).toContain("github.event_name == 'pull_request'"); + expect(iosJob).toContain("needs.preflight.outputs.run_macos == 'true'"); expect(iosJob).toContain("run: pnpm ios:screenshots"); expect(iosJob).toContain("Upload iOS release screenshot evidence"); expect(iosJob).toContain("apps/ios/build/SnapshotTestResults/*.xcresult");