mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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 <steipete@gmail.com>
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -23,6 +23,7 @@ data class GatewayRequestFrame(
|
||||
val id: String,
|
||||
val method: String,
|
||||
val params: JsonElement? = null,
|
||||
val traceparent: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<GatewayWsMessageHandlerParams["extraHandlers"][string]>,
|
||||
) {
|
||||
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<typeof createDispatcher>["dispatcher"],
|
||||
client: GatewayWsClient,
|
||||
id: string,
|
||||
traceparent?: string,
|
||||
): Promise<void> {
|
||||
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<WebSocket> {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${params.port}`);
|
||||
trackConnectChallengeNonce(ws);
|
||||
await new Promise<void>((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<string, DiagnosticTraceContext | undefined>();
|
||||
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<void>((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<void> | 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<void>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown> = {}) => {
|
||||
sendRequest: (
|
||||
id: string,
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
traceparent?: string,
|
||||
) => {
|
||||
sendMessage(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
...(traceparent ? { traceparent } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
sendConnect: (id: string, params: Record<string, unknown>) => {
|
||||
sendConnect: (id: string, params: Record<string, unknown>, 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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user