feat(diagnostics-otel): add content capture controls

Add opt-in diagnostics OTEL content capture controls, keep raw content export default-off, and guard the content-capture tests against magic truncation bounds.
This commit is contained in:
Vincent Koc
2026-04-24 16:41:28 -07:00
committed by GitHub
parent fbf8b216c6
commit d4d4a8c14e
12 changed files with 455 additions and 9 deletions

View File

@@ -123,6 +123,8 @@ const SPAN_ID = "00f067aa0ba902b7";
const CHILD_SPAN_ID = "1111111111111111";
const GRANDCHILD_SPAN_ID = "2222222222222222";
const PROTO_KEY = "__proto__";
const MAX_TEST_OTEL_CONTENT_ATTRIBUTE_CHARS = 4096;
const OTEL_TRUNCATED_SUFFIX_MAX_CHARS = 20;
function createLogger() {
return {
@@ -137,10 +139,13 @@ type OtelContextFlags = {
traces?: boolean;
metrics?: boolean;
logs?: boolean;
captureContent?: NonNullable<
NonNullable<OpenClawPluginServiceContext["config"]["diagnostics"]>["otel"]
>["captureContent"];
};
function createOtelContext(
endpoint: string,
{ traces = false, metrics = false, logs = false }: OtelContextFlags = {},
{ traces = false, metrics = false, logs = false, captureContent }: OtelContextFlags = {},
): OpenClawPluginServiceContext {
return {
config: {
@@ -153,6 +158,7 @@ function createOtelContext(
traces,
metrics,
logs,
...(captureContent !== undefined ? { captureContent } : {}),
},
},
},
@@ -723,6 +729,122 @@ describe("diagnostics-otel service", () => {
await service.stop?.(ctx);
});
test("does not export model or tool content unless capture is explicitly enabled", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
await service.start(ctx);
emitDiagnosticEvent({
type: "model.call.completed",
runId: "run-1",
callId: "call-1",
provider: "openai",
model: "gpt-5.4",
durationMs: 80,
inputMessages: ["private user prompt"],
outputMessages: ["private model reply"],
systemPrompt: "private system prompt",
} as Parameters<typeof emitDiagnosticEvent>[0]);
emitDiagnosticEvent({
type: "tool.execution.completed",
runId: "run-1",
toolName: "read",
toolCallId: "tool-1",
durationMs: 20,
toolInput: "private tool input",
toolOutput: "private tool output",
} as Parameters<typeof emitDiagnosticEvent>[0]);
await flushDiagnosticEvents();
const modelCall = telemetryState.tracer.startSpan.mock.calls.find(
(call) => call[0] === "openclaw.model.call",
);
const toolCall = telemetryState.tracer.startSpan.mock.calls.find(
(call) => call[0] === "openclaw.tool.execution",
);
expect(modelCall?.[1]).toEqual({
attributes: expect.not.objectContaining({
"openclaw.content.input_messages": expect.anything(),
"openclaw.content.output_messages": expect.anything(),
"openclaw.content.system_prompt": expect.anything(),
}),
startTime: expect.any(Number),
});
expect(toolCall?.[1]).toEqual({
attributes: expect.not.objectContaining({
"openclaw.content.tool_input": expect.anything(),
"openclaw.content.tool_output": expect.anything(),
}),
startTime: expect.any(Number),
});
await service.stop?.(ctx);
});
test("exports bounded redacted content when capture fields are opted in", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, {
traces: true,
metrics: true,
captureContent: {
enabled: true,
inputMessages: true,
outputMessages: true,
toolInputs: true,
toolOutputs: true,
systemPrompt: true,
},
});
await service.start(ctx);
emitDiagnosticEvent({
type: "model.call.completed",
runId: "run-1",
callId: "call-1",
provider: "openai",
model: "gpt-5.4",
durationMs: 80,
inputMessages: ["use key sk-1234567890abcdef1234567890abcdef"], // pragma: allowlist secret
outputMessages: ["model reply"],
systemPrompt: "system prompt",
} as Parameters<typeof emitDiagnosticEvent>[0]);
emitDiagnosticEvent({
type: "tool.execution.completed",
runId: "run-1",
toolName: "read",
toolCallId: "tool-1",
durationMs: 20,
toolInput: "tool input",
toolOutput: "x".repeat(6000),
} as Parameters<typeof emitDiagnosticEvent>[0]);
await flushDiagnosticEvents();
const modelCall = telemetryState.tracer.startSpan.mock.calls.find(
(call) => call[0] === "openclaw.model.call",
);
const toolCall = telemetryState.tracer.startSpan.mock.calls.find(
(call) => call[0] === "openclaw.tool.execution",
);
const modelAttrs = (modelCall?.[1] as { attributes?: Record<string, unknown> } | undefined)
?.attributes;
const toolAttrs = (toolCall?.[1] as { attributes?: Record<string, unknown> } | undefined)
?.attributes;
expect(modelAttrs).toMatchObject({
"openclaw.content.output_messages": "model reply",
"openclaw.content.system_prompt": "system prompt",
});
expect(String(modelAttrs?.["openclaw.content.input_messages"])).not.toContain(
"sk-1234567890abcdef1234567890abcdef", // pragma: allowlist secret
);
expect(toolAttrs).toMatchObject({
"openclaw.content.tool_input": "tool input",
});
expect(String(toolAttrs?.["openclaw.content.tool_output"]).length).toBeLessThanOrEqual(
MAX_TEST_OTEL_CONTENT_ATTRIBUTE_CHARS + OTEL_TRUNCATED_SUFFIX_MAX_CHARS,
);
await service.stop?.(ctx);
});
test("ignores invalid diagnostic event trace parents", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });

View File

@@ -40,6 +40,8 @@ const DROPPED_OTEL_ATTRIBUTE_KEYS = new Set([
"openclaw.traceId",
]);
const LOW_CARDINALITY_VALUE_RE = /^[A-Za-z0-9_.:-]{1,120}$/u;
const MAX_OTEL_CONTENT_ATTRIBUTE_CHARS = 4 * 1024;
const MAX_OTEL_CONTENT_ARRAY_ITEMS = 16;
const MAX_OTEL_LOG_BODY_CHARS = 4 * 1024;
const MAX_OTEL_LOG_ATTRIBUTE_COUNT = 64;
const MAX_OTEL_LOG_ATTRIBUTE_VALUE_CHARS = 4 * 1024;
@@ -48,6 +50,22 @@ const OTEL_LOG_RAW_ATTRIBUTE_KEY_RE = /^[A-Za-z0-9_.:-]{1,64}$/u;
const OTEL_LOG_ATTRIBUTE_KEY_RE = /^[A-Za-z0-9_.:-]{1,96}$/u;
const BLOCKED_OTEL_LOG_ATTRIBUTE_KEYS = new Set(["__proto__", "prototype", "constructor"]);
type OtelContentCapturePolicy = {
inputMessages: boolean;
outputMessages: boolean;
toolInputs: boolean;
toolOutputs: boolean;
systemPrompt: boolean;
};
const NO_CONTENT_CAPTURE: OtelContentCapturePolicy = {
inputMessages: false,
outputMessages: false,
toolInputs: false,
toolOutputs: false,
systemPrompt: false,
};
function normalizeEndpoint(endpoint?: string): string | undefined {
const trimmed = endpoint?.trim();
return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
@@ -119,6 +137,95 @@ function normalizeOtelLogString(value: string, maxChars: number): string {
return redactSensitiveText(clampOtelLogText(value, maxChars));
}
function resolveContentCapturePolicy(value: unknown): OtelContentCapturePolicy {
if (value === true) {
return {
inputMessages: true,
outputMessages: true,
toolInputs: true,
toolOutputs: true,
systemPrompt: false,
};
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return NO_CONTENT_CAPTURE;
}
const config = value as Record<string, unknown>;
if (config.enabled !== true) {
return NO_CONTENT_CAPTURE;
}
return {
inputMessages: config.inputMessages === true,
outputMessages: config.outputMessages === true,
toolInputs: config.toolInputs === true,
toolOutputs: config.toolOutputs === true,
systemPrompt: config.systemPrompt === true,
};
}
function normalizeOtelContentValue(value: unknown): string | undefined {
if (typeof value === "string") {
return normalizeOtelLogString(value, MAX_OTEL_CONTENT_ATTRIBUTE_CHARS);
}
if (Array.isArray(value)) {
const items: string[] = [];
for (const item of value.slice(0, MAX_OTEL_CONTENT_ARRAY_ITEMS)) {
if (typeof item === "string") {
items.push(item);
}
}
if (items.length > 0) {
return normalizeOtelLogString(items.join("\n"), MAX_OTEL_CONTENT_ATTRIBUTE_CHARS);
}
}
return undefined;
}
function assignOtelContentAttribute(
attributes: Record<string, string | number | boolean>,
key: string,
value: unknown,
): void {
const normalized = normalizeOtelContentValue(value);
if (normalized) {
attributes[key] = normalized;
}
}
function assignOtelModelContentAttributes(
attributes: Record<string, string | number | boolean>,
event: Record<string, unknown>,
policy: OtelContentCapturePolicy,
): void {
if (policy.inputMessages) {
assignOtelContentAttribute(attributes, "openclaw.content.input_messages", event.inputMessages);
}
if (policy.outputMessages) {
assignOtelContentAttribute(
attributes,
"openclaw.content.output_messages",
event.outputMessages,
);
}
if (policy.systemPrompt) {
assignOtelContentAttribute(attributes, "openclaw.content.system_prompt", event.systemPrompt);
}
}
function assignOtelToolContentAttributes(
attributes: Record<string, string | number | boolean>,
event: Record<string, unknown>,
policy: OtelContentCapturePolicy,
): void {
if (policy.toolInputs) {
assignOtelContentAttribute(attributes, "openclaw.content.tool_input", event.toolInput);
}
if (policy.toolOutputs) {
assignOtelContentAttribute(attributes, "openclaw.content.tool_output", event.toolOutput);
}
}
function assignOtelLogAttribute(
attributes: Record<string, string | number | boolean>,
key: string,
@@ -285,6 +392,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const serviceName =
otel.serviceName?.trim() || process.env.OTEL_SERVICE_NAME || DEFAULT_SERVICE_NAME;
const sampleRate = resolveSampleRate(otel.sampleRate);
const contentCapturePolicy = resolveContentCapturePolicy(otel.captureContent);
const tracesEnabled = otel.traces !== false;
const metricsEnabled = otel.metrics !== false;
@@ -856,6 +964,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (evt.transport) {
spanAttrs["openclaw.transport"] = evt.transport;
}
assignOtelModelContentAttributes(
spanAttrs,
evt as unknown as Record<string, unknown>,
contentCapturePolicy,
);
const span = spanWithDuration("openclaw.model.call", spanAttrs, evt.durationMs, {
endTimeMs: evt.ts,
});
@@ -886,6 +999,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (evt.transport) {
spanAttrs["openclaw.transport"] = evt.transport;
}
assignOtelModelContentAttributes(
spanAttrs,
evt as unknown as Record<string, unknown>,
contentCapturePolicy,
);
const span = spanWithDuration("openclaw.model.call", spanAttrs, evt.durationMs, {
endTimeMs: evt.ts,
});
@@ -913,6 +1031,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
...paramsSummaryAttrs(evt.paramsSummary),
};
addRunAttrs(spanAttrs, evt);
assignOtelToolContentAttributes(
spanAttrs,
evt as unknown as Record<string, unknown>,
contentCapturePolicy,
);
const span = spanWithDuration("openclaw.tool.execution", spanAttrs, evt.durationMs, {
endTimeMs: evt.ts,
});
@@ -941,6 +1064,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (evt.errorCode) {
spanAttrs["openclaw.errorCode"] = lowCardinalityAttr(evt.errorCode, "other");
}
assignOtelToolContentAttributes(
spanAttrs,
evt as unknown as Record<string, unknown>,
contentCapturePolicy,
);
const span = spanWithDuration("openclaw.tool.execution", spanAttrs, evt.durationMs, {
endTimeMs: evt.ts,
});