mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:50:43 +00:00
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:
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user