Skip to content

Commit 3e3bba4

Browse files
authored
feat(diagnostics): emit exec process telemetry (#71451)
1 parent 188bce4 commit 3e3bba4

8 files changed

Lines changed: 294 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#70424) Thanks @jlapenna.
910
- Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#70424) Thanks @jlapenna.
1011
- Control UI: refine the agent Tool Access panel with compact live-tool chips, collapsible tool groups, direct per-tool toggles, and clearer runtime/source provenance. (#71405) Thanks @BunsDev.
1112
- Memory-core/hybrid search: expose raw `vectorScore` and `textScore` alongside the combined `score` on hybrid memory search results, so callers can inspect vector-versus-text retrieval contribution before temporal decay or MMR reordering. Fixes #68166. (#68286) Thanks @ajfonthemove.

docs/logging.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,12 @@ Queue + session:
216216
- `run.attempt`: run retry/attempt metadata.
217217
- `diagnostic.heartbeat`: aggregate counters (webhooks/queue/session).
218218

219+
Exec:
220+
221+
- `exec.process.completed`: terminal exec process outcome, duration, target, mode,
222+
exit code, and failure kind. Command text and working directories are not
223+
included.
224+
219225
### Enable diagnostics (no exporter)
220226

221227
Use this if you want diagnostics events available to plugins or custom sinks:
@@ -352,6 +358,11 @@ Queues + sessions:
352358
- `openclaw.session.stuck_age_ms` (histogram, attrs: `openclaw.state`)
353359
- `openclaw.run.attempt` (counter, attrs: `openclaw.attempt`)
354360

361+
Exec:
362+
363+
- `openclaw.exec.duration_ms` (histogram, attrs: `openclaw.exec.target`,
364+
`openclaw.exec.mode`, `openclaw.outcome`, `openclaw.failureKind`)
365+
355366
### Exported spans (names + key attributes)
356367

357368
- `openclaw.model.usage`
@@ -367,6 +378,10 @@ Queues + sessions:
367378
- `openclaw.tool.execution`
368379
- `gen_ai.tool.name`, `openclaw.toolName`, `openclaw.errorCategory`,
369380
`openclaw.tool.params.*`
381+
- `openclaw.exec`
382+
- `openclaw.exec.target`, `openclaw.exec.mode`, `openclaw.outcome`,
383+
`openclaw.failureKind`, `openclaw.exec.command_length`,
384+
`openclaw.exec.exit_code`, `openclaw.exec.timed_out`
370385
- `openclaw.webhook.processed`
371386
- `openclaw.channel`, `openclaw.webhook`, `openclaw.chatId`
372387
- `openclaw.webhook.error`

extensions/diagnostics-otel/src/service.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,67 @@ describe("diagnostics-otel service", () => {
817817
await service.stop?.(ctx);
818818
});
819819

820+
test("exports exec process spans without command text", async () => {
821+
const service = createDiagnosticsOtelService();
822+
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
823+
await service.start(ctx);
824+
825+
emitDiagnosticEvent({
826+
type: "exec.process.completed",
827+
target: "host",
828+
mode: "child",
829+
outcome: "failed",
830+
durationMs: 30,
831+
commandLength: 42,
832+
exitCode: 1,
833+
timedOut: false,
834+
failureKind: "runtime-error",
835+
});
836+
await flushDiagnosticEvents();
837+
838+
expect(telemetryState.histograms.get("openclaw.exec.duration_ms")?.record).toHaveBeenCalledWith(
839+
30,
840+
expect.objectContaining({
841+
"openclaw.exec.target": "host",
842+
"openclaw.exec.mode": "child",
843+
"openclaw.outcome": "failed",
844+
"openclaw.failureKind": "runtime-error",
845+
}),
846+
);
847+
848+
const execCall = telemetryState.tracer.startSpan.mock.calls.find(
849+
(call) => call[0] === "openclaw.exec",
850+
);
851+
expect(execCall?.[1]).toMatchObject({
852+
attributes: {
853+
"openclaw.exec.target": "host",
854+
"openclaw.exec.mode": "child",
855+
"openclaw.outcome": "failed",
856+
"openclaw.exec.command_length": 42,
857+
"openclaw.exec.exit_code": 1,
858+
"openclaw.exec.timed_out": false,
859+
"openclaw.failureKind": "runtime-error",
860+
},
861+
startTime: expect.any(Number),
862+
});
863+
expect(execCall?.[1]).toEqual({
864+
attributes: expect.not.objectContaining({
865+
"openclaw.exec.command": expect.anything(),
866+
"openclaw.exec.workdir": expect.anything(),
867+
"openclaw.sessionKey": expect.anything(),
868+
}),
869+
startTime: expect.any(Number),
870+
});
871+
872+
const execSpan = telemetryState.spans.find((span) => span.name === "openclaw.exec");
873+
expect(execSpan?.setStatus).toHaveBeenCalledWith({
874+
code: 2,
875+
message: "runtime-error",
876+
});
877+
expect(execSpan?.end).toHaveBeenCalledWith(expect.any(Number));
878+
await service.stop?.(ctx);
879+
});
880+
820881
test("does not export model or tool content unless capture is explicitly enabled", async () => {
821882
const service = createDiagnosticsOtelService();
822883
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });

extensions/diagnostics-otel/src/service.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,10 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
557557
description: "Tool execution duration",
558558
},
559559
);
560+
const execProcessDurationHistogram = meter.createHistogram("openclaw.exec.duration_ms", {
561+
unit: "ms",
562+
description: "Exec process duration",
563+
});
560564

561565
let recordLogRecord:
562566
| ((evt: Extract<DiagnosticEventPayload, { type: "log.record" }>) => void)
@@ -1087,6 +1091,48 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
10871091
span.end(evt.ts);
10881092
};
10891093

1094+
const recordExecProcessCompleted = (
1095+
evt: Extract<DiagnosticEventPayload, { type: "exec.process.completed" }>,
1096+
) => {
1097+
const attrs: Record<string, string | number> = {
1098+
"openclaw.exec.target": evt.target,
1099+
"openclaw.exec.mode": evt.mode,
1100+
"openclaw.outcome": evt.outcome,
1101+
};
1102+
if (evt.failureKind) {
1103+
attrs["openclaw.failureKind"] = evt.failureKind;
1104+
}
1105+
execProcessDurationHistogram.record(evt.durationMs, attrs);
1106+
if (!tracesEnabled) {
1107+
return;
1108+
}
1109+
1110+
const spanAttrs: Record<string, string | number | boolean> = {
1111+
...attrs,
1112+
"openclaw.exec.command_length": evt.commandLength,
1113+
};
1114+
if (typeof evt.exitCode === "number") {
1115+
spanAttrs["openclaw.exec.exit_code"] = evt.exitCode;
1116+
}
1117+
if (evt.exitSignal) {
1118+
spanAttrs["openclaw.exec.exit_signal"] = lowCardinalityAttr(evt.exitSignal, "other");
1119+
}
1120+
if (evt.timedOut !== undefined) {
1121+
spanAttrs["openclaw.exec.timed_out"] = evt.timedOut;
1122+
}
1123+
1124+
const span = spanWithDuration("openclaw.exec", spanAttrs, evt.durationMs, {
1125+
endTimeMs: evt.ts,
1126+
});
1127+
if (evt.outcome === "failed") {
1128+
span.setStatus({
1129+
code: SpanStatusCode.ERROR,
1130+
...(evt.failureKind ? { message: evt.failureKind } : {}),
1131+
});
1132+
}
1133+
span.end(evt.ts);
1134+
};
1135+
10901136
const recordHeartbeat = (
10911137
evt: Extract<DiagnosticEventPayload, { type: "diagnostic.heartbeat" }>,
10921138
) => {
@@ -1147,6 +1193,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
11471193
case "tool.execution.error":
11481194
recordToolExecutionError(evt);
11491195
return;
1196+
case "exec.process.completed":
1197+
recordExecProcessCompleted(evt);
1198+
return;
11501199
case "log.record":
11511200
recordLogRecord?.(evt);
11521201
return;

src/agents/bash-tools.exec-runtime.pty-fallback.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vitest";
2+
import {
3+
onInternalDiagnosticEvent,
4+
resetDiagnosticEventsForTest,
5+
type DiagnosticEventPayload,
6+
} from "../infra/diagnostic-events.js";
27
import type { ManagedRun, SpawnInput } from "../process/supervisor/index.js";
38

49
let listRunningSessions: typeof import("./bash-process-registry.js").listRunningSessions;
@@ -56,6 +61,7 @@ beforeEach(() => {
5661

5762
afterEach(() => {
5863
resetProcessRegistryForTests();
64+
resetDiagnosticEventsForTest();
5965
vi.clearAllMocks();
6066
});
6167

@@ -101,3 +107,53 @@ test("exec cleans session state when PTY fallback spawn also fails", async () =>
101107

102108
expect(listRunningSessions()).toHaveLength(0);
103109
});
110+
111+
function flushDiagnosticEvents() {
112+
return new Promise<void>((resolve) => setImmediate(resolve));
113+
}
114+
115+
test("exec emits bounded process diagnostics without command text", async () => {
116+
supervisorSpawnMock.mockImplementationOnce(async (input: SpawnInput) =>
117+
createSuccessfulRun(input),
118+
);
119+
const events: DiagnosticEventPayload[] = [];
120+
const unsubscribe = onInternalDiagnosticEvent((event) => {
121+
events.push(event);
122+
});
123+
try {
124+
const command = "printf super-secret-value";
125+
const handle = await runExecProcess({
126+
command,
127+
workdir: process.cwd(),
128+
env: {},
129+
usePty: false,
130+
warnings: [],
131+
maxOutput: 20_000,
132+
pendingMaxOutput: 20_000,
133+
notifyOnExit: false,
134+
sessionKey: "session-1",
135+
timeoutSec: 5,
136+
});
137+
138+
await handle.promise;
139+
await flushDiagnosticEvents();
140+
141+
const event = events.find((item) => item.type === "exec.process.completed");
142+
expect(event).toMatchObject({
143+
type: "exec.process.completed",
144+
target: "host",
145+
mode: "child",
146+
outcome: "completed",
147+
durationMs: expect.any(Number),
148+
commandLength: command.length,
149+
exitCode: 0,
150+
sessionKey: "session-1",
151+
});
152+
const serialized = JSON.stringify(event);
153+
expect(serialized).not.toContain("printf");
154+
expect(serialized).not.toContain("super-secret-value");
155+
expect(serialized).not.toContain(process.cwd());
156+
} finally {
157+
unsubscribe();
158+
}
159+
});

src/agents/bash-tools.exec-runtime.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from "node:path";
22
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
3+
import { emitDiagnosticEvent } from "../infra/diagnostic-events.js";
34
import {
45
DEFAULT_EXEC_APPROVAL_TIMEOUT_MS,
56
resolveExecApprovalAllowedDecisions,
@@ -165,6 +166,40 @@ export type ExecProcessHandle = {
165166
disableUpdates: () => void;
166167
};
167168

169+
function normalizeExecExitSignal(signal: NodeJS.Signals | number | null): string | undefined {
170+
if (signal === null) {
171+
return undefined;
172+
}
173+
return String(signal);
174+
}
175+
176+
function emitExecProcessCompleted(params: {
177+
command: string;
178+
mode: "child" | "pty";
179+
outcome: ExecProcessOutcome;
180+
sessionKey?: string;
181+
target: "host" | "sandbox";
182+
}): void {
183+
const exitSignal = normalizeExecExitSignal(params.outcome.exitSignal);
184+
emitDiagnosticEvent({
185+
type: "exec.process.completed",
186+
target: params.target,
187+
mode: params.mode,
188+
outcome: params.outcome.status,
189+
durationMs: params.outcome.durationMs,
190+
commandLength: params.command.length,
191+
...(params.sessionKey?.trim() ? { sessionKey: params.sessionKey.trim() } : {}),
192+
...(typeof params.outcome.exitCode === "number" ? { exitCode: params.outcome.exitCode } : {}),
193+
...(exitSignal ? { exitSignal } : {}),
194+
...(params.outcome.status === "failed"
195+
? {
196+
timedOut: params.outcome.timedOut,
197+
failureKind: params.outcome.failureKind,
198+
}
199+
: {}),
200+
});
201+
}
202+
168203
export function renderExecHostLabel(host: ExecHost) {
169204
return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node";
170205
}
@@ -523,6 +558,7 @@ export async function runExecProcess(opts: {
523558
const startedAt = Date.now();
524559
const sessionId = createSessionSlug();
525560
const execCommand = opts.execCommand ?? opts.command;
561+
const diagnosticTarget = opts.sandbox ? "sandbox" : "host";
526562
const supervisor = getProcessSupervisor();
527563
const shellRuntimeEnv: Record<string, string> = {
528564
...opts.env,
@@ -759,11 +795,33 @@ export async function runExecProcess(opts: {
759795
} catch (retryErr) {
760796
markExited(session, null, null, "failed");
761797
maybeNotifyOnExit(session, "failed");
798+
emitExecProcessCompleted({
799+
command: opts.command,
800+
mode: "child",
801+
outcome: buildExecRuntimeErrorOutcome({
802+
error: retryErr,
803+
aggregated: session.aggregated.trim(),
804+
durationMs: Date.now() - startedAt,
805+
}),
806+
sessionKey: opts.sessionKey,
807+
target: diagnosticTarget,
808+
});
762809
throw retryErr;
763810
}
764811
} else {
765812
markExited(session, null, null, "failed");
766813
maybeNotifyOnExit(session, "failed");
814+
emitExecProcessCompleted({
815+
command: opts.command,
816+
mode: spawnSpec.mode,
817+
outcome: buildExecRuntimeErrorOutcome({
818+
error: err,
819+
aggregated: session.aggregated.trim(),
820+
durationMs: Date.now() - startedAt,
821+
}),
822+
sessionKey: opts.sessionKey,
823+
target: diagnosticTarget,
824+
});
767825
throw err;
768826
}
769827
}
@@ -799,17 +857,32 @@ export async function runExecProcess(opts: {
799857
token: sandboxFinalizeToken,
800858
});
801859
}
860+
emitExecProcessCompleted({
861+
command: opts.command,
862+
mode: usingPty ? "pty" : "child",
863+
outcome,
864+
sessionKey: opts.sessionKey,
865+
target: diagnosticTarget,
866+
});
802867
return outcome;
803868
})
804869
.catch((err): ExecProcessOutcome => {
805870
updatesDisabled = true;
806871
markExited(session, null, null, "failed");
807872
maybeNotifyOnExit(session, "failed");
808-
return buildExecRuntimeErrorOutcome({
873+
const outcome = buildExecRuntimeErrorOutcome({
809874
error: err,
810875
aggregated: session.aggregated.trim(),
811876
durationMs: Date.now() - startedAt,
812877
});
878+
emitExecProcessCompleted({
879+
command: opts.command,
880+
mode: usingPty ? "pty" : "child",
881+
outcome,
882+
sessionKey: opts.sessionKey,
883+
target: diagnosticTarget,
884+
});
885+
return outcome;
813886
});
814887

815888
return {

0 commit comments

Comments
 (0)