mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:30:43 +00:00
feat(diagnostics): add trace context carrier (#70924)
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Diagnostics/OTEL: add a lightweight diagnostic trace-context carrier for future span correlation without adding OTEL SDK state to core. Thanks @vincentkoc.
|
||||
- Control UI/chat: add a Steer action on queued messages so a browser follow-up can be injected into the active run without retyping it.
|
||||
- Control UI/Talk: add browser WebRTC realtime voice sessions backed by OpenAI Realtime, with Gateway-minted ephemeral client secrets and `openclaw_agent_consult` handoff to the full OpenClaw agent.
|
||||
- Agents/tools: add optional per-call `timeoutMs` support for image, video, music, and TTS generation tools so agents can extend provider request timeouts only when a specific generation needs it.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
b9c997ae9dba2c534942c1c79e8285f773ab7481c282e8a981e362e8132f944f plugin-sdk-api-baseline.json
|
||||
c2f8370ae879d4404a9ac7f7aa7f43859e990f04f4872cbd8bc48da05d4bc671 plugin-sdk-api-baseline.jsonl
|
||||
3ce0dadfe0cac406051ff95ee8201a508d588e634b98ac22659e6b010c3641f6 plugin-sdk-api-baseline.json
|
||||
69c9058277b146196a3a3ef49fe193e42987a3642a233732370c9ddae60ddf62 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resetDiagnosticEventsForTest,
|
||||
setDiagnosticsEnabledForProcess,
|
||||
} from "./diagnostic-events.js";
|
||||
import { createDiagnosticTraceContext } from "./diagnostic-trace-context.js";
|
||||
|
||||
describe("diagnostic-events", () => {
|
||||
beforeEach(() => {
|
||||
@@ -88,6 +89,31 @@ describe("diagnostic-events", () => {
|
||||
expect(seen).toEqual(["webhook.received"]);
|
||||
});
|
||||
|
||||
it("carries explicit trace context without creating retained trace state", () => {
|
||||
const trace = createDiagnosticTraceContext({
|
||||
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
spanId: "00f067aa0ba902b7",
|
||||
});
|
||||
const events: Array<{ trace: typeof trace | undefined; type: string }> = [];
|
||||
const stop = onDiagnosticEvent((event) => {
|
||||
events.push({ trace: event.trace, type: event.type });
|
||||
});
|
||||
|
||||
emitDiagnosticEvent({
|
||||
type: "message.queued",
|
||||
source: "telegram",
|
||||
trace,
|
||||
});
|
||||
stop();
|
||||
emitDiagnosticEvent({
|
||||
type: "message.queued",
|
||||
source: "telegram",
|
||||
trace,
|
||||
});
|
||||
|
||||
expect(events).toEqual([{ trace, type: "message.queued" }]);
|
||||
});
|
||||
|
||||
it("skips event enrichment and subscribers when diagnostics are disabled", () => {
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
const seen: string[] = [];
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { DiagnosticTraceContext } from "./diagnostic-trace-context.js";
|
||||
|
||||
export type DiagnosticSessionState = "idle" | "processing" | "waiting";
|
||||
|
||||
type DiagnosticBaseEvent = {
|
||||
ts: number;
|
||||
seq: number;
|
||||
trace?: DiagnosticTraceContext;
|
||||
};
|
||||
|
||||
export type DiagnosticUsageEvent = DiagnosticBaseEvent & {
|
||||
|
||||
93
src/infra/diagnostic-trace-context.test.ts
Normal file
93
src/infra/diagnostic-trace-context.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createChildDiagnosticTraceContext,
|
||||
createDiagnosticTraceContext,
|
||||
formatDiagnosticTraceparent,
|
||||
isValidDiagnosticSpanId,
|
||||
isValidDiagnosticTraceFlags,
|
||||
isValidDiagnosticTraceId,
|
||||
parseDiagnosticTraceparent,
|
||||
} from "./diagnostic-trace-context.js";
|
||||
|
||||
const TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736";
|
||||
const SPAN_ID = "00f067aa0ba902b7";
|
||||
const CHILD_SPAN_ID = "7ad6b9a982deb2c9";
|
||||
|
||||
describe("diagnostic-trace-context", () => {
|
||||
it("validates W3C trace ids, span ids, and trace flags", () => {
|
||||
expect(isValidDiagnosticTraceId(TRACE_ID)).toBe(true);
|
||||
expect(isValidDiagnosticSpanId(SPAN_ID)).toBe(true);
|
||||
expect(isValidDiagnosticTraceFlags("01")).toBe(true);
|
||||
|
||||
expect(isValidDiagnosticTraceId("0".repeat(32))).toBe(false);
|
||||
expect(isValidDiagnosticTraceId("xyz")).toBe(false);
|
||||
expect(isValidDiagnosticSpanId("0".repeat(16))).toBe(false);
|
||||
expect(isValidDiagnosticSpanId("xyz")).toBe(false);
|
||||
expect(isValidDiagnosticTraceFlags("xyz")).toBe(false);
|
||||
});
|
||||
|
||||
it("parses and formats traceparent values", () => {
|
||||
const traceparent = `00-${TRACE_ID}-${SPAN_ID}-01`;
|
||||
|
||||
expect(parseDiagnosticTraceparent(traceparent)).toEqual({
|
||||
traceId: TRACE_ID,
|
||||
spanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
});
|
||||
expect(
|
||||
formatDiagnosticTraceparent({
|
||||
traceId: TRACE_ID,
|
||||
spanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
}),
|
||||
).toBe(traceparent);
|
||||
});
|
||||
|
||||
it("rejects malformed traceparent values", () => {
|
||||
expect(parseDiagnosticTraceparent(undefined)).toBeUndefined();
|
||||
expect(parseDiagnosticTraceparent(`ff-${TRACE_ID}-${SPAN_ID}-01`)).toBeUndefined();
|
||||
expect(parseDiagnosticTraceparent(`00-${"0".repeat(32)}-${SPAN_ID}-01`)).toBeUndefined();
|
||||
expect(parseDiagnosticTraceparent(`00-${TRACE_ID}-${"0".repeat(16)}-01`)).toBeUndefined();
|
||||
expect(parseDiagnosticTraceparent(`00-${TRACE_ID}-${SPAN_ID}-xyz`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates a normalized context from explicit fields or traceparent", () => {
|
||||
expect(
|
||||
createDiagnosticTraceContext({
|
||||
traceId: TRACE_ID.toUpperCase(),
|
||||
spanId: SPAN_ID.toUpperCase(),
|
||||
traceFlags: "00",
|
||||
}),
|
||||
).toEqual({
|
||||
traceId: TRACE_ID,
|
||||
spanId: SPAN_ID,
|
||||
traceFlags: "00",
|
||||
});
|
||||
|
||||
expect(createDiagnosticTraceContext({ traceparent: `00-${TRACE_ID}-${SPAN_ID}-01` })).toEqual({
|
||||
traceId: TRACE_ID,
|
||||
spanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates child contexts without retaining parent references or self-parenting", () => {
|
||||
const parent = createDiagnosticTraceContext({
|
||||
traceId: TRACE_ID,
|
||||
spanId: SPAN_ID,
|
||||
});
|
||||
const child = createChildDiagnosticTraceContext(parent, {
|
||||
spanId: CHILD_SPAN_ID,
|
||||
});
|
||||
|
||||
expect(child).toEqual({
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
});
|
||||
expect(
|
||||
createChildDiagnosticTraceContext(parent, { spanId: SPAN_ID }).parentSpanId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
133
src/infra/diagnostic-trace-context.ts
Normal file
133
src/infra/diagnostic-trace-context.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const TRACEPARENT_VERSION = "00";
|
||||
const DEFAULT_TRACE_FLAGS = "01";
|
||||
const TRACE_ID_RE = /^[0-9a-f]{32}$/;
|
||||
const SPAN_ID_RE = /^[0-9a-f]{16}$/;
|
||||
const TRACE_FLAGS_RE = /^[0-9a-f]{2}$/;
|
||||
|
||||
export type DiagnosticTraceContext = {
|
||||
/** W3C trace id, 32 lowercase hex chars. */
|
||||
traceId: string;
|
||||
/** Current span id, 16 lowercase hex chars. */
|
||||
spanId?: string;
|
||||
/** Parent span id, 16 lowercase hex chars. */
|
||||
parentSpanId?: string;
|
||||
/** W3C trace flags, 2 lowercase hex chars. Defaults to sampled. */
|
||||
traceFlags?: string;
|
||||
};
|
||||
|
||||
export type DiagnosticTraceContextInput = Partial<DiagnosticTraceContext> & {
|
||||
traceparent?: string;
|
||||
};
|
||||
|
||||
function randomHex(bytes: number): string {
|
||||
return randomBytes(bytes).toString("hex");
|
||||
}
|
||||
|
||||
function isNonZeroHex(value: string): boolean {
|
||||
return !/^0+$/.test(value);
|
||||
}
|
||||
|
||||
export function isValidDiagnosticTraceId(value: unknown): value is string {
|
||||
return typeof value === "string" && TRACE_ID_RE.test(value) && isNonZeroHex(value);
|
||||
}
|
||||
|
||||
export function isValidDiagnosticSpanId(value: unknown): value is string {
|
||||
return typeof value === "string" && SPAN_ID_RE.test(value) && isNonZeroHex(value);
|
||||
}
|
||||
|
||||
export function isValidDiagnosticTraceFlags(value: unknown): value is string {
|
||||
return typeof value === "string" && TRACE_FLAGS_RE.test(value);
|
||||
}
|
||||
|
||||
function normalizeTraceId(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.toLowerCase();
|
||||
return isValidDiagnosticTraceId(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeSpanId(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.toLowerCase();
|
||||
return isValidDiagnosticSpanId(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeTraceFlags(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.toLowerCase();
|
||||
return isValidDiagnosticTraceFlags(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function parseDiagnosticTraceparent(
|
||||
traceparent: string | undefined,
|
||||
): DiagnosticTraceContext | undefined {
|
||||
const parts = traceparent?.trim().toLowerCase().split("-");
|
||||
if (!parts || parts.length !== 4) {
|
||||
return undefined;
|
||||
}
|
||||
const [version, traceId, spanId, traceFlags] = parts;
|
||||
if (version !== TRACEPARENT_VERSION) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedTraceId = normalizeTraceId(traceId);
|
||||
const normalizedSpanId = normalizeSpanId(spanId);
|
||||
const normalizedTraceFlags = normalizeTraceFlags(traceFlags);
|
||||
if (!normalizedTraceId || !normalizedSpanId || !normalizedTraceFlags) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
traceId: normalizedTraceId,
|
||||
spanId: normalizedSpanId,
|
||||
traceFlags: normalizedTraceFlags,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDiagnosticTraceparent(
|
||||
context: DiagnosticTraceContext | undefined,
|
||||
): string | undefined {
|
||||
if (!context?.spanId) {
|
||||
return undefined;
|
||||
}
|
||||
const traceId = normalizeTraceId(context.traceId);
|
||||
const spanId = normalizeSpanId(context.spanId);
|
||||
const traceFlags = normalizeTraceFlags(context.traceFlags) ?? DEFAULT_TRACE_FLAGS;
|
||||
if (!traceId || !spanId) {
|
||||
return undefined;
|
||||
}
|
||||
return `${TRACEPARENT_VERSION}-${traceId}-${spanId}-${traceFlags}`;
|
||||
}
|
||||
|
||||
export function createDiagnosticTraceContext(
|
||||
input: DiagnosticTraceContextInput = {},
|
||||
): DiagnosticTraceContext {
|
||||
const parsed = parseDiagnosticTraceparent(input.traceparent);
|
||||
const traceId = normalizeTraceId(input.traceId) ?? parsed?.traceId ?? randomHex(16);
|
||||
const spanId = normalizeSpanId(input.spanId) ?? parsed?.spanId ?? randomHex(8);
|
||||
const parentSpanId = normalizeSpanId(input.parentSpanId);
|
||||
return {
|
||||
traceId,
|
||||
spanId,
|
||||
...(parentSpanId && parentSpanId !== spanId ? { parentSpanId } : {}),
|
||||
traceFlags: normalizeTraceFlags(input.traceFlags) ?? parsed?.traceFlags ?? DEFAULT_TRACE_FLAGS,
|
||||
};
|
||||
}
|
||||
|
||||
export function createChildDiagnosticTraceContext(
|
||||
parent: DiagnosticTraceContext,
|
||||
input: Omit<DiagnosticTraceContextInput, "traceId" | "traceparent"> = {},
|
||||
): DiagnosticTraceContext {
|
||||
const parentSpanId = normalizeSpanId(input.parentSpanId) ?? normalizeSpanId(parent.spanId);
|
||||
return createDiagnosticTraceContext({
|
||||
traceId: parent.traceId,
|
||||
spanId: input.spanId,
|
||||
parentSpanId,
|
||||
traceFlags: input.traceFlags ?? parent.traceFlags,
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,17 @@
|
||||
// Keep this list additive and scoped to the bundled diagnostics-otel surface.
|
||||
|
||||
export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js";
|
||||
export type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js";
|
||||
export { emitDiagnosticEvent, onDiagnosticEvent } from "../infra/diagnostic-events.js";
|
||||
export {
|
||||
createChildDiagnosticTraceContext,
|
||||
createDiagnosticTraceContext,
|
||||
formatDiagnosticTraceparent,
|
||||
isValidDiagnosticSpanId,
|
||||
isValidDiagnosticTraceFlags,
|
||||
isValidDiagnosticTraceId,
|
||||
parseDiagnosticTraceparent,
|
||||
} from "../infra/diagnostic-trace-context.js";
|
||||
export { registerLogTransport } from "../logging/logger.js";
|
||||
export { redactSensitiveText } from "../logging/redact.js";
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
|
||||
Reference in New Issue
Block a user