fix: wire anthropic payload log diagnostics (#1501) (thanks @parubets)

This commit is contained in:
Peter Steinberger 2026-01-24 05:33:22 +00:00
parent 07bc85b7fb
commit e85abaca2b
8 changed files with 213 additions and 10 deletions

View File

@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
- CLI: add live auth probes to `clawdbot models status` for per-profile verification. - CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`. - CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3. - Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
- Agents: add diagnostics-configured Anthropic payload logging. (#1501) Thanks @parubets.
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc. - Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. - Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.

View File

@ -95,6 +95,29 @@ Console logs are **TTY-aware** and formatted for readability:
Console formatting is controlled by `logging.consoleStyle`. Console formatting is controlled by `logging.consoleStyle`.
## Anthropic payload log (debugging)
For Anthropic-only runs, you can enable a dedicated JSONL log that captures the
exact request payload (as sent) plus per-run usage stats. This log includes full
prompt/message data; treat it as sensitive.
```json
{
"diagnostics": {
"anthropicPayloadLog": {
"enabled": true,
"filePath": "/path/to/anthropic-payload.jsonl"
}
}
}
```
Defaults + overrides:
- Default path: `$CLAWDBOT_STATE_DIR/logs/anthropic-payload.jsonl`
- Env enable: `CLAWDBOT_ANTHROPIC_PAYLOAD_LOG=1`
- Env path: `CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE=/path/to/anthropic-payload.jsonl`
## Configuring logging ## Configuring logging
All logging configuration lives under `logging` in `~/.clawdbot/clawdbot.json`. All logging configuration lives under `logging` in `~/.clawdbot/clawdbot.json`.

View File

@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import { createAnthropicPayloadLogger } from "./anthropic-payload-log.js";
describe("createAnthropicPayloadLogger", () => {
it("returns null when diagnostics payload logging is disabled", () => {
const logger = createAnthropicPayloadLogger({
cfg: {} as ClawdbotConfig,
env: {},
modelApi: "anthropic-messages",
});
expect(logger).toBeNull();
});
it("returns null when model api is not anthropic", () => {
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
},
},
},
env: {},
modelApi: "openai",
writer: {
filePath: "memory",
write: () => undefined,
},
});
expect(logger).toBeNull();
});
it("honors diagnostics config and expands file paths", () => {
const lines: string[] = [];
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
filePath: "~/.clawdbot/logs/anthropic-payload.jsonl",
},
},
},
env: {},
modelApi: "anthropic-messages",
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
expect(logger).not.toBeNull();
expect(logger?.filePath).toBe(resolveUserPath("~/.clawdbot/logs/anthropic-payload.jsonl"));
logger?.recordUsage([
{
role: "assistant",
usage: {
input: 12,
},
} as unknown as {
role: string;
usage: { input: number };
},
]);
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
expect(event.stage).toBe("usage");
expect(event.usage).toEqual({ input: 12 });
});
it("records request payloads and forwards onPayload", async () => {
const lines: string[] = [];
let forwarded: unknown;
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
},
},
},
env: {},
modelApi: "anthropic-messages",
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
const streamFn = ((_, __, options) => {
options?.onPayload?.({ hello: "world" });
return Promise.resolve(undefined);
}) as StreamFn;
const wrapped = logger?.wrapStreamFn(streamFn);
await wrapped?.(
{ api: "anthropic-messages" } as unknown as { api: string },
{},
{
onPayload: (payload) => {
forwarded = payload;
},
},
);
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
expect(event.stage).toBe("request");
expect(event.payload).toEqual({ hello: "world" });
expect(event.payloadDigest).toBeTruthy();
expect(forwarded).toEqual({ hello: "world" });
});
it("records errors when usage is missing", () => {
const lines: string[] = [];
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
},
},
},
env: {},
modelApi: "anthropic-messages",
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
logger?.recordUsage([], new Error("boom"));
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
expect(event.stage).toBe("usage");
expect(event.error).toContain("boom");
});
});

View File

@ -5,7 +5,9 @@ import path from "node:path";
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core"; import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai"; import type { Api, Model } from "@mariozechner/pi-ai";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js"; import { resolveStateDir } from "../config/paths.js";
import { formatErrorMessage } from "../infra/errors.js";
import { parseBooleanValue } from "../utils/boolean.js"; import { parseBooleanValue } from "../utils/boolean.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
@ -33,6 +35,11 @@ type PayloadLogConfig = {
filePath: string; filePath: string;
}; };
type PayloadLogInit = {
cfg?: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
};
type PayloadLogWriter = { type PayloadLogWriter = {
filePath: string; filePath: string;
write: (line: string) => void; write: (line: string) => void;
@ -41,9 +48,12 @@ type PayloadLogWriter = {
const writers = new Map<string, PayloadLogWriter>(); const writers = new Map<string, PayloadLogWriter>();
const log = createSubsystemLogger("agent/anthropic-payload"); const log = createSubsystemLogger("agent/anthropic-payload");
function resolvePayloadLogConfig(env: NodeJS.ProcessEnv): PayloadLogConfig { function resolvePayloadLogConfig(params: PayloadLogInit): PayloadLogConfig {
const enabled = parseBooleanValue(env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG) ?? false; const env = params.env ?? process.env;
const fileOverride = env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE?.trim(); const config = params.cfg?.diagnostics?.anthropicPayloadLog;
const envEnabled = parseBooleanValue(env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG);
const enabled = envEnabled ?? config?.enabled ?? false;
const fileOverride = config?.filePath?.trim() || env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE?.trim();
const filePath = fileOverride const filePath = fileOverride
? resolveUserPath(fileOverride) ? resolveUserPath(fileOverride)
: path.join(resolveStateDir(env), "logs", "anthropic-payload.jsonl"); : path.join(resolveStateDir(env), "logs", "anthropic-payload.jsonl");
@ -112,11 +122,13 @@ function findLastAssistantUsage(messages: AgentMessage[]): Record<string, unknow
export type AnthropicPayloadLogger = { export type AnthropicPayloadLogger = {
enabled: true; enabled: true;
filePath: string;
wrapStreamFn: (streamFn: StreamFn) => StreamFn; wrapStreamFn: (streamFn: StreamFn) => StreamFn;
recordUsage: (messages: AgentMessage[], error?: unknown) => void; recordUsage: (messages: AgentMessage[], error?: unknown) => void;
}; };
export function createAnthropicPayloadLogger(params: { export function createAnthropicPayloadLogger(params: {
cfg?: ClawdbotConfig;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
runId?: string; runId?: string;
sessionId?: string; sessionId?: string;
@ -125,12 +137,14 @@ export function createAnthropicPayloadLogger(params: {
modelId?: string; modelId?: string;
modelApi?: string | null; modelApi?: string | null;
workspaceDir?: string; workspaceDir?: string;
writer?: PayloadLogWriter;
}): AnthropicPayloadLogger | null { }): AnthropicPayloadLogger | null {
const env = params.env ?? process.env; const env = params.env ?? process.env;
const cfg = resolvePayloadLogConfig(env); const cfg = resolvePayloadLogConfig({ env, cfg: params.cfg });
if (!cfg.enabled) return null; if (!cfg.enabled) return null;
if (params.modelApi !== "anthropic-messages") return null;
const writer = getWriter(cfg.filePath); const writer = params.writer ?? getWriter(cfg.filePath);
const base: Omit<PayloadLogEvent, "ts" | "stage"> = { const base: Omit<PayloadLogEvent, "ts" | "stage"> = {
runId: params.runId, runId: params.runId,
sessionId: params.sessionId, sessionId: params.sessionId,
@ -163,7 +177,7 @@ export function createAnthropicPayloadLogger(params: {
options?.onPayload?.(payload); options?.onPayload?.(payload);
}; };
return streamFn(model, context, { return streamFn(model, context, {
...(options ?? {}), ...options,
onPayload: nextOnPayload, onPayload: nextOnPayload,
}); });
}; };
@ -178,7 +192,7 @@ export function createAnthropicPayloadLogger(params: {
...base, ...base,
ts: new Date().toISOString(), ts: new Date().toISOString(),
stage: "usage", stage: "usage",
error: String(error), error: formatErrorMessage(error),
}); });
} }
return; return;
@ -188,7 +202,7 @@ export function createAnthropicPayloadLogger(params: {
ts: new Date().toISOString(), ts: new Date().toISOString(),
stage: "usage", stage: "usage",
usage, usage,
error: error ? String(error) : undefined, error: error ? formatErrorMessage(error) : undefined,
}); });
log.info("anthropic usage", { log.info("anthropic usage", {
runId: params.runId, runId: params.runId,
@ -197,6 +211,6 @@ export function createAnthropicPayloadLogger(params: {
}); });
}; };
log.info("anthropic payload logger enabled", { filePath: writer.filePath }); log.info("anthropic payload logger enabled", { filePath: cfg.filePath });
return { enabled: true, wrapStreamFn, recordUsage }; return { enabled: true, filePath: cfg.filePath, wrapStreamFn, recordUsage };
} }

View File

@ -460,6 +460,7 @@ export async function runEmbeddedAttempt(
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
}); });
const anthropicPayloadLogger = createAnthropicPayloadLogger({ const anthropicPayloadLogger = createAnthropicPayloadLogger({
cfg: params.config,
env: process.env, env: process.env,
runId: params.runId, runId: params.runId,
sessionId: activeSession.sessionId, sessionId: activeSession.sessionId,

View File

@ -121,6 +121,8 @@ const FIELD_LABELS: Record<string, string> = {
"diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages",
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
"diagnostics.anthropicPayloadLog.enabled": "Anthropic Payload Log Enabled",
"diagnostics.anthropicPayloadLog.filePath": "Anthropic Payload Log File Path",
"agents.list.*.identity.avatar": "Identity Avatar", "agents.list.*.identity.avatar": "Identity Avatar",
"gateway.remote.url": "Remote Gateway URL", "gateway.remote.url": "Remote Gateway URL",
"gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshTarget": "Remote Gateway SSH Target",
@ -390,6 +392,10 @@ const FIELD_HELP: Record<string, string> = {
"Include full message payloads in trace output (default: true).", "Include full message payloads in trace output (default: true).",
"diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).", "diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).",
"diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).", "diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).",
"diagnostics.anthropicPayloadLog.enabled":
"Log Anthropic request payloads + usage for embedded runs (default: false).",
"diagnostics.anthropicPayloadLog.filePath":
"JSONL output path for Anthropic payload logs (default: $CLAWDBOT_STATE_DIR/logs/anthropic-payload.jsonl).",
"tools.exec.applyPatch.enabled": "tools.exec.applyPatch.enabled":
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
"tools.exec.applyPatch.allowModels": "tools.exec.applyPatch.allowModels":

View File

@ -133,10 +133,16 @@ export type DiagnosticsCacheTraceConfig = {
includeSystem?: boolean; includeSystem?: boolean;
}; };
export type DiagnosticsAnthropicPayloadLogConfig = {
enabled?: boolean;
filePath?: string;
};
export type DiagnosticsConfig = { export type DiagnosticsConfig = {
enabled?: boolean; enabled?: boolean;
otel?: DiagnosticsOtelConfig; otel?: DiagnosticsOtelConfig;
cacheTrace?: DiagnosticsCacheTraceConfig; cacheTrace?: DiagnosticsCacheTraceConfig;
anthropicPayloadLog?: DiagnosticsAnthropicPayloadLogConfig;
}; };
export type WebReconnectConfig = { export type WebReconnectConfig = {

View File

@ -86,6 +86,13 @@ export const ClawdbotSchema = z
}) })
.strict() .strict()
.optional(), .optional(),
anthropicPayloadLog: z
.object({
enabled: z.boolean().optional(),
filePath: z.string().optional(),
})
.strict()
.optional(),
}) })
.strict() .strict()
.optional(), .optional(),