Add Claude observability tracing and diagnostics UI

This commit is contained in:
2026-02-24 12:50:31 -05:00
parent 6863c1da0b
commit 691591d279
22 changed files with 1898 additions and 32 deletions

View File

@@ -0,0 +1,821 @@
import { randomUUID } from "node:crypto";
import { appendFile, mkdir } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import type { Options, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type {
ClaudeObservabilityMode,
ClaudeObservabilityRuntimeConfig,
ClaudeObservabilityVerbosity,
} from "../config.js";
import type { JsonObject, JsonValue } from "../agents/types.js";
const MAX_STRING_LENGTH = 320;
const MAX_ARRAY_ITEMS = 20;
const MAX_OBJECT_KEYS = 60;
const MAX_DEPTH = 6;
const NON_SECRET_TOKEN_KEYS = new Set([
"input_tokens",
"output_tokens",
"total_tokens",
"cache_creation_input_tokens",
"cache_read_input_tokens",
"ephemeral_1h_input_tokens",
"ephemeral_5m_input_tokens",
"token_input",
"token_output",
"token_total",
"tokencount",
"token_count",
"tool_use_id",
"parent_tool_use_id",
"task_id",
"session_id",
]);
type ClaudeTraceContext = {
sessionId: string;
nodeId: string;
attempt: number;
depth: number;
};
type ClaudeTraceRecord = {
id: string;
timestamp: string;
source: "claude_sdk";
stage:
| "query.started"
| "query.message"
| "query.stderr"
| "query.completed"
| "query.error";
message: string;
sessionId: string;
nodeId: string;
attempt: number;
depth: number;
sdkSessionId?: string;
sdkMessageType?: string;
sdkMessageSubtype?: string;
data?: JsonObject;
};
function truncate(value: string, maxLength = MAX_STRING_LENGTH): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength)}...`;
}
function isSensitiveKey(key: string): boolean {
const normalized = key.trim().toLowerCase();
if (!normalized) {
return false;
}
if (NON_SECRET_TOKEN_KEYS.has(normalized)) {
return false;
}
if (/(api[_-]?key|secret|password|authorization|cookie)/i.test(key)) {
return true;
}
if (/(auth[_-]?token|access[_-]?token|refresh[_-]?token|id[_-]?token|oauth)/i.test(key)) {
return true;
}
return normalized === "token";
}
function toJsonPrimitive(value: unknown): JsonValue {
if (value === null) {
return null;
}
if (typeof value === "string") {
return truncate(value);
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : String(value);
}
if (typeof value === "boolean") {
return value;
}
if (typeof value === "bigint") {
return String(value);
}
if (typeof value === "undefined") {
return null;
}
return truncate(String(value));
}
function sanitizeJsonValue(value: unknown, depth = 0): JsonValue {
if (depth >= MAX_DEPTH) {
return "[depth_limit]";
}
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint" ||
typeof value === "undefined"
) {
return toJsonPrimitive(value);
}
if (Array.isArray(value)) {
const output = value.slice(0, MAX_ARRAY_ITEMS).map((entry) => sanitizeJsonValue(entry, depth + 1));
if (value.length > MAX_ARRAY_ITEMS) {
output.push(`[+${String(value.length - MAX_ARRAY_ITEMS)} more]`);
}
return output;
}
if (typeof value === "object") {
const output: JsonObject = {};
const entries = Object.entries(value as Record<string, unknown>);
const limited = entries.slice(0, MAX_OBJECT_KEYS);
for (const [key, entryValue] of limited) {
if (isSensitiveKey(key)) {
output[key] = "[redacted]";
continue;
}
output[key] = sanitizeJsonValue(entryValue, depth + 1);
}
if (entries.length > MAX_OBJECT_KEYS) {
output.__truncated_keys = entries.length - MAX_OBJECT_KEYS;
}
return output;
}
return truncate(String(value));
}
function readString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function readNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function toMessageRecord(message: SDKMessage): Record<string, unknown> {
return message as unknown as Record<string, unknown>;
}
function toMessageSubtype(message: SDKMessage): string | undefined {
return readString(toMessageRecord(message).subtype);
}
function toMessageSessionId(message: SDKMessage): string | undefined {
return readString(toMessageRecord(message).session_id);
}
function toTaskNotificationSummary(message: SDKMessage): {
summary: string;
data?: JsonObject;
} {
const raw = toMessageRecord(message);
const status = readString(raw.status) ?? "unknown";
const data: JsonObject = {
status,
};
const taskId = readString(raw.task_id);
if (taskId) {
data.taskId = taskId;
}
const summaryText = readString(raw.summary);
if (summaryText) {
data.summary = truncate(summaryText);
}
const outputFile = readString(raw.output_file);
if (outputFile) {
data.outputFile = outputFile;
}
if (raw.usage !== undefined) {
data.usage = sanitizeJsonValue(raw.usage);
}
return {
summary: `Task notification: ${status}.`,
data,
};
}
function toTaskStartedSummary(message: SDKMessage): {
summary: string;
data?: JsonObject;
} {
const raw = toMessageRecord(message);
const data: JsonObject = {};
const taskId = readString(raw.task_id);
if (taskId) {
data.taskId = taskId;
}
const description = readString(raw.description);
if (description) {
data.description = truncate(description);
}
const taskType = readString(raw.task_type);
if (taskType) {
data.taskType = taskType;
}
const toolUseId = readString(raw.tool_use_id);
if (toolUseId) {
data.toolUseId = toolUseId;
}
return {
summary: "Task started.",
...(Object.keys(data).length > 0 ? { data } : {}),
};
}
function toMessageSummary(message: SDKMessage): {
summary: string;
data?: JsonObject;
} {
const subtype = toMessageSubtype(message);
const raw = toMessageRecord(message);
if (message.type === "result") {
if (message.subtype === "success") {
return {
summary: "Claude query result success.",
data: {
stopReason: message.stop_reason ?? null,
numTurns: message.num_turns,
usage: sanitizeJsonValue(message.usage) as JsonObject,
totalCostUsd: message.total_cost_usd,
},
};
}
return {
summary: `Claude query result ${message.subtype}.`,
data: {
stopReason: message.stop_reason ?? null,
numTurns: message.num_turns,
usage: sanitizeJsonValue(message.usage) as JsonObject,
totalCostUsd: message.total_cost_usd,
errors: sanitizeJsonValue(message.errors),
},
};
}
if (message.type === "tool_progress") {
return {
summary: `Tool progress: ${message.tool_name}.`,
data: {
toolName: message.tool_name,
toolUseId: message.tool_use_id,
elapsedTimeSeconds: message.elapsed_time_seconds,
parentToolUseId: message.parent_tool_use_id ?? null,
...(message.task_id ? { taskId: message.task_id } : {}),
},
};
}
if (message.type === "tool_use_summary") {
return {
summary: "Tool use summary emitted.",
data: {
summary: truncate(message.summary),
precedingToolUseIds: sanitizeJsonValue(message.preceding_tool_use_ids),
},
};
}
if (message.type === "stream_event") {
const data: JsonObject = {};
const eventType = readString((raw.event as Record<string, unknown> | undefined)?.type);
if (eventType) {
data.eventType = eventType;
}
const parentToolUseId = readString(raw.parent_tool_use_id);
if (parentToolUseId) {
data.parentToolUseId = parentToolUseId;
}
return {
summary: "Partial assistant stream event emitted.",
...(Object.keys(data).length > 0 ? { data } : {}),
};
}
if (message.type === "auth_status") {
return {
summary: message.isAuthenticating ? "Authentication in progress." : "Authentication status update.",
data: {
isAuthenticating: message.isAuthenticating,
output: sanitizeJsonValue(message.output),
...(message.error ? { error: truncate(message.error) } : {}),
},
};
}
if (message.type === "assistant") {
return {
summary: "Assistant message emitted.",
data: {
parentToolUseId: message.parent_tool_use_id ?? null,
...(message.error ? { error: message.error } : {}),
},
};
}
if (message.type === "user") {
const data: JsonObject = {
parentToolUseId: (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? null,
};
const isSynthetic = readBoolean(raw.isSynthetic);
if (isSynthetic !== undefined) {
data.isSynthetic = isSynthetic;
}
const isReplay = readBoolean(raw.isReplay);
if (isReplay !== undefined) {
data.isReplay = isReplay;
}
return {
summary: "User message emitted.",
data,
};
}
if (subtype === "task_notification") {
return toTaskNotificationSummary(message);
}
if (subtype === "task_started") {
return toTaskStartedSummary(message);
}
if (message.type === "system" && subtype === "files_persisted") {
const files = Array.isArray(raw.files) ? raw.files : [];
const failed = Array.isArray(raw.failed) ? raw.failed : [];
return {
summary: "System event: files_persisted.",
data: {
persistedFileCount: files.length,
failedFileCount: failed.length,
},
};
}
if (message.type === "system" && subtype === "compact_boundary") {
return {
summary: "System event: compact_boundary.",
data: {
compactMetadata: sanitizeJsonValue(raw.compact_metadata),
},
};
}
if (message.type === "system" && subtype === "status") {
const data: JsonObject = {
status: readString(raw.status) ?? "none",
};
const permissionMode = readString(raw.permissionMode);
if (permissionMode) {
data.permissionMode = permissionMode;
}
return {
summary: "System event: status.",
data,
};
}
if (message.type === "system" && (subtype === "hook_started" || subtype === "hook_progress" || subtype === "hook_response")) {
const data: JsonObject = {
...(subtype ? { subtype } : {}),
...(readString(raw.hook_id) ? { hookId: readString(raw.hook_id) } : {}),
...(readString(raw.hook_name) ? { hookName: readString(raw.hook_name) } : {}),
...(readString(raw.hook_event) ? { hookEvent: readString(raw.hook_event) } : {}),
...(readString(raw.outcome) ? { outcome: readString(raw.outcome) } : {}),
};
if (raw.exit_code !== undefined) {
data.exitCode = sanitizeJsonValue(raw.exit_code);
}
return {
summary: `System event: ${subtype}.`,
data,
};
}
if (message.type === "system") {
return {
summary: subtype ? `System event: ${subtype}.` : "System event emitted.",
data: subtype ? { subtype } : undefined,
};
}
if (message.type === "rate_limit") {
return {
summary: "Rate limit event emitted.",
data: sanitizeJsonValue(raw) as JsonObject,
};
}
if (message.type === "prompt_suggestion") {
const data: JsonObject = {
...(readString(raw.prompt) ? { prompt: truncate(readString(raw.prompt) as string) } : {}),
...(readString(raw.suggestion) ? { suggestion: truncate(readString(raw.suggestion) as string) } : {}),
};
return {
summary: "Prompt suggestion emitted.",
...(Object.keys(data).length > 0 ? { data } : {}),
};
}
return {
summary: `Claude SDK message received (${message.type}).`,
};
}
function toRecord(input: {
stage: ClaudeTraceRecord["stage"];
message: string;
context: ClaudeTraceContext;
sdkMessageType?: string;
sdkMessageSubtype?: string;
sdkSessionId?: string;
data?: JsonObject;
}): ClaudeTraceRecord {
return {
id: randomUUID(),
timestamp: new Date().toISOString(),
source: "claude_sdk",
stage: input.stage,
message: input.message,
sessionId: input.context.sessionId,
nodeId: input.context.nodeId,
attempt: input.context.attempt,
depth: input.context.depth,
...(input.sdkMessageType ? { sdkMessageType: input.sdkMessageType } : {}),
...(input.sdkMessageSubtype ? { sdkMessageSubtype: input.sdkMessageSubtype } : {}),
...(input.sdkSessionId ? { sdkSessionId: input.sdkSessionId } : {}),
...(input.data ? { data: input.data } : {}),
};
}
export function summarizeClaudeMessage(
message: SDKMessage,
verbosity: ClaudeObservabilityVerbosity,
): {
messageType: string;
messageSubtype?: string;
sdkSessionId?: string;
summary: string;
data?: JsonObject;
} {
const messageSubtype = toMessageSubtype(message);
const sdkSessionId = toMessageSessionId(message);
const summary = toMessageSummary(message);
if (verbosity === "full") {
return {
messageType: message.type,
...(messageSubtype ? { messageSubtype } : {}),
...(sdkSessionId ? { sdkSessionId } : {}),
summary: summary.summary,
data: {
message: sanitizeJsonValue(message) as JsonObject,
},
};
}
return {
messageType: message.type,
...(messageSubtype ? { messageSubtype } : {}),
...(sdkSessionId ? { sdkSessionId } : {}),
summary: summary.summary,
...(summary.data ? { data: summary.data } : {}),
};
}
export class ClaudeObservabilityLogger {
private readonly mode: ClaudeObservabilityMode;
private readonly verbosity: ClaudeObservabilityVerbosity;
private readonly logPath: string;
private readonly includePartialMessages: boolean;
private readonly debug: boolean;
private readonly debugLogPath?: string;
private readonly pendingWrites = new Set<Promise<void>>();
private readonly stdoutProgressByKey = new Map<string, {
lastEmittedAt: number;
suppressed: number;
}>();
private readonly fileProgressByKey = new Map<string, {
lastEmittedAt: number;
suppressed: number;
}>();
private readonly stdoutStreamByKey = new Map<string, {
lastEmittedAt: number;
suppressed: number;
}>();
private readonly fileStreamByKey = new Map<string, {
lastEmittedAt: number;
suppressed: number;
}>();
private fileWriteFailureCount = 0;
constructor(input: {
workspaceRoot: string;
config: ClaudeObservabilityRuntimeConfig;
}) {
this.mode = input.config.mode;
this.verbosity = input.config.verbosity;
this.logPath = resolve(input.workspaceRoot, input.config.logPath);
this.includePartialMessages = input.config.includePartialMessages;
this.debug = input.config.debug;
this.debugLogPath = input.config.debugLogPath
? resolve(input.workspaceRoot, input.config.debugLogPath)
: undefined;
}
isEnabled(): boolean {
return this.mode !== "off";
}
toOptionOverrides(input: {
context: ClaudeTraceContext;
}): Pick<Options, "includePartialMessages" | "debug" | "debugFile" | "stderr"> {
return {
includePartialMessages: this.includePartialMessages,
debug: this.debug || this.debugLogPath !== undefined,
...(this.debugLogPath ? { debugFile: this.debugLogPath } : {}),
stderr: (data: string): void => {
this.record({
stage: "query.stderr",
message: "Claude SDK stderr output.",
context: input.context,
data: {
stderr: sanitizeJsonValue(data),
},
});
},
};
}
recordQueryStarted(input: {
context: ClaudeTraceContext;
data?: JsonObject;
}): void {
this.record({
stage: "query.started",
message: "Claude query started.",
context: input.context,
...(input.data ? { data: input.data } : {}),
});
}
recordMessage(input: {
context: ClaudeTraceContext;
message: SDKMessage;
}): void {
const summarized = summarizeClaudeMessage(input.message, this.verbosity);
this.record({
stage: "query.message",
message: summarized.summary,
context: input.context,
sdkMessageType: summarized.messageType,
sdkMessageSubtype: summarized.messageSubtype,
sdkSessionId: summarized.sdkSessionId,
...(summarized.data ? { data: summarized.data } : {}),
});
}
recordQueryCompleted(input: {
context: ClaudeTraceContext;
data?: JsonObject;
}): void {
this.record({
stage: "query.completed",
message: "Claude query completed.",
context: input.context,
...(input.data ? { data: input.data } : {}),
});
}
recordQueryError(input: {
context: ClaudeTraceContext;
error: unknown;
}): void {
const errorMessage = input.error instanceof Error ? input.error.message : String(input.error);
this.record({
stage: "query.error",
message: "Claude query failed.",
context: input.context,
data: {
error: truncate(errorMessage),
},
});
}
async close(): Promise<void> {
await Promise.all([...this.pendingWrites]);
}
private record(input: {
stage: ClaudeTraceRecord["stage"];
message: string;
context: ClaudeTraceContext;
sdkMessageType?: string;
sdkMessageSubtype?: string;
sdkSessionId?: string;
data?: JsonObject;
}): void {
if (!this.isEnabled()) {
return;
}
const record = toRecord(input);
if (this.mode === "stdout" || this.mode === "both") {
const stdoutRecord = this.toStdoutRecord(record);
if (stdoutRecord) {
console.log(`[claude-trace] ${JSON.stringify(stdoutRecord)}`);
}
}
if (this.mode === "file" || this.mode === "both") {
const fileRecord = this.toFileRecord(record);
if (!fileRecord) {
return;
}
const line = JSON.stringify(fileRecord);
const write = mkdir(dirname(this.logPath), { recursive: true })
.then(() => appendFile(this.logPath, `${line}\n`, "utf8"))
.catch((error: unknown) => {
this.reportFileWriteFailure(error);
})
.finally(() => {
this.pendingWrites.delete(write);
});
this.pendingWrites.add(write);
}
}
private toStdoutRecord(record: ClaudeTraceRecord): ClaudeTraceRecord | undefined {
return this.toFilteredMessageRecord(record, "stdout");
}
private toFileRecord(record: ClaudeTraceRecord): ClaudeTraceRecord | undefined {
return this.toFilteredMessageRecord(record, "file");
}
private toFilteredMessageRecord(
record: ClaudeTraceRecord,
destination: "stdout" | "file",
): ClaudeTraceRecord | undefined {
if (record.stage !== "query.message") {
return record;
}
if (!record.sdkMessageType) {
return record;
}
if (record.sdkMessageType === "tool_progress") {
return this.toSampledToolProgressRecord(record, destination);
}
if (record.sdkMessageType === "stream_event") {
if (!this.includePartialMessages) {
return undefined;
}
return this.toSampledStreamEventRecord(record, destination);
}
if (record.sdkMessageType === "auth_status") {
const data = record.data;
const isAuthenticating = data?.isAuthenticating === true;
const hasError = typeof data?.error === "string" && data.error.trim().length > 0;
if (hasError || !isAuthenticating) {
return record;
}
return undefined;
}
return record;
}
private toSampledToolProgressRecord(
record: ClaudeTraceRecord,
destination: "stdout" | "file",
): ClaudeTraceRecord | undefined {
const now = Date.now();
const minIntervalMs = destination === "stdout" ? 1000 : 2000;
const rawToolUseId = record.data?.toolUseId;
const toolUseId = typeof rawToolUseId === "string" ? rawToolUseId : "unknown";
const key = `${destination}:${record.sessionId}:${record.nodeId}:${toolUseId}`;
const progressByKey = destination === "stdout" ? this.stdoutProgressByKey : this.fileProgressByKey;
const state = progressByKey.get(key);
if (!state) {
progressByKey.set(key, {
lastEmittedAt: now,
suppressed: 0,
});
return record;
}
if (now - state.lastEmittedAt < minIntervalMs) {
state.suppressed += 1;
return undefined;
}
state.lastEmittedAt = now;
const suppressed = state.suppressed;
state.suppressed = 0;
if (suppressed < 1) {
return record;
}
const nextData: JsonObject = {
...(record.data ?? {}),
suppressedSinceLastEmit: suppressed,
};
return {
...record,
data: nextData,
};
}
private toSampledStreamEventRecord(
record: ClaudeTraceRecord,
destination: "stdout" | "file",
): ClaudeTraceRecord | undefined {
const now = Date.now();
const minIntervalMs = destination === "stdout" ? 700 : 1200;
const key = `${destination}:${record.sessionId}:${record.nodeId}:stream`;
const streamByKey = destination === "stdout" ? this.stdoutStreamByKey : this.fileStreamByKey;
const state = streamByKey.get(key);
if (!state) {
streamByKey.set(key, {
lastEmittedAt: now,
suppressed: 0,
});
return record;
}
if (now - state.lastEmittedAt < minIntervalMs) {
state.suppressed += 1;
return undefined;
}
state.lastEmittedAt = now;
const suppressed = state.suppressed;
state.suppressed = 0;
if (suppressed < 1) {
return record;
}
const nextData: JsonObject = {
...(record.data ?? {}),
suppressedStreamEventsSinceLastEmit: suppressed,
};
return {
...record,
data: nextData,
};
}
private reportFileWriteFailure(error: unknown): void {
this.fileWriteFailureCount += 1;
if (this.fileWriteFailureCount <= 5) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[claude-trace] failed to append trace log to ${this.logPath}: ${truncate(message, 180)}`,
);
return;
}
if (this.fileWriteFailureCount === 6) {
console.warn("[claude-trace] additional trace-log write failures suppressed.");
}
}
}

View File

@@ -0,0 +1,85 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
export type ClaudeTraceEvent = {
timestamp: string;
message: string;
stage?: string;
sessionId?: string;
sdkMessageType?: string;
sdkMessageSubtype?: string;
data?: unknown;
} & Record<string, unknown>;
type ClaudeTraceFilter = {
sessionId?: string;
limit?: number;
};
function safeParseLine(line: string): ClaudeTraceEvent | undefined {
const trimmed = line.trim();
if (!trimmed) {
return undefined;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object") {
return undefined;
}
const record = parsed as Record<string, unknown>;
if (typeof record.timestamp !== "string" || typeof record.message !== "string") {
return undefined;
}
return record as ClaudeTraceEvent;
} catch {
return undefined;
}
}
export async function readClaudeTraceEvents(logPath: string): Promise<ClaudeTraceEvent[]> {
const absolutePath = resolve(logPath);
let content = "";
try {
content = await readFile(absolutePath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
const parsed: ClaudeTraceEvent[] = [];
for (const line of content.split(/\r?\n/)) {
const event = safeParseLine(line);
if (event) {
parsed.push(event);
}
}
parsed.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
return parsed;
}
export function filterClaudeTraceEvents(
events: readonly ClaudeTraceEvent[],
filter: ClaudeTraceFilter,
): ClaudeTraceEvent[] {
const filtered: ClaudeTraceEvent[] = [];
for (const event of events) {
if (filter.sessionId && event.sessionId !== filter.sessionId) {
continue;
}
filtered.push(event);
}
if (!filter.limit || filter.limit < 1 || filtered.length <= filter.limit) {
return filtered;
}
return filtered.slice(-filter.limit);
}

View File

@@ -39,6 +39,7 @@ export type UiConfigSnapshot = {
stateRoot: string;
projectContextPath: string;
runtimeEventLogPath: string;
claudeTraceLogPath: string;
securityAuditLogPath: string;
};
};
@@ -107,6 +108,7 @@ function toSnapshot(config: Readonly<AppConfig>, envFilePath: string): UiConfigS
stateRoot: config.orchestration.stateRoot,
projectContextPath: config.orchestration.projectContextPath,
runtimeEventLogPath: config.runtimeEvents.logPath,
claudeTraceLogPath: config.provider.claudeObservability.logPath,
securityAuditLogPath: config.security.auditLogPath,
},
};

View File

@@ -10,6 +10,7 @@ import { isDomainEventType, type DomainEventEmission } from "../agents/domain-ev
import type { ActorExecutionInput, ActorExecutionResult, ActorExecutor } from "../agents/pipeline.js";
import { isRecord, type JsonObject, type JsonValue } from "../agents/types.js";
import { createSessionContext, type SessionContext } from "../examples/session-context.js";
import { ClaudeObservabilityLogger } from "./claude-observability.js";
export type RunProvider = "codex" | "claude";
@@ -17,6 +18,7 @@ export type ProviderRunRuntime = {
provider: RunProvider;
config: Readonly<AppConfig>;
sessionContext: SessionContext;
claudeObservability: ClaudeObservabilityLogger;
close: () => Promise<void>;
};
@@ -416,6 +418,40 @@ type ClaudeTurnResult = {
usage: ProviderUsage;
};
function toClaudeTraceContext(actorInput: ActorExecutionInput): {
sessionId: string;
nodeId: string;
attempt: number;
depth: number;
} {
return {
sessionId: actorInput.sessionId,
nodeId: actorInput.node.id,
attempt: actorInput.attempt,
depth: actorInput.depth,
};
}
function toProviderUsageJson(usage: ProviderUsage): JsonObject {
const output: JsonObject = {};
if (typeof usage.tokenInput === "number") {
output.tokenInput = usage.tokenInput;
}
if (typeof usage.tokenOutput === "number") {
output.tokenOutput = usage.tokenOutput;
}
if (typeof usage.tokenTotal === "number") {
output.tokenTotal = usage.tokenTotal;
}
if (typeof usage.durationMs === "number") {
output.durationMs = usage.durationMs;
}
if (typeof usage.costUsd === "number") {
output.costUsd = usage.costUsd;
}
return output;
}
function buildClaudeOptions(input: {
runtime: ProviderRunRuntime;
actorInput: ActorExecutionInput;
@@ -433,6 +469,7 @@ function buildClaudeOptions(input: {
...runtime.sessionContext.runtimeInjection.env,
...buildClaudeAuthEnv(runtime.config.provider),
};
const traceContext = toClaudeTraceContext(actorInput);
return {
maxTurns: CLAUDE_PROVIDER_MAX_TURNS,
@@ -449,6 +486,9 @@ function buildClaudeOptions(input: {
canUseTool: actorInput.mcp.createClaudeCanUseTool(),
cwd: runtime.sessionContext.runtimeInjection.workingDirectory,
env: runtimeEnv,
...runtime.claudeObservability.toOptionOverrides({
context: traceContext,
}),
outputFormat: CLAUDE_OUTPUT_FORMAT,
};
}
@@ -458,10 +498,19 @@ async function runClaudeTurn(input: {
actorInput: ActorExecutionInput;
prompt: string;
}): Promise<ClaudeTurnResult> {
const traceContext = toClaudeTraceContext(input.actorInput);
const options = buildClaudeOptions({
runtime: input.runtime,
actorInput: input.actorInput,
});
input.runtime.claudeObservability.recordQueryStarted({
context: traceContext,
data: {
...(options.model ? { model: options.model } : {}),
maxTurns: options.maxTurns ?? CLAUDE_PROVIDER_MAX_TURNS,
cwd: input.runtime.sessionContext.runtimeInjection.workingDirectory,
},
});
const startedAt = Date.now();
const stream = query({
@@ -472,6 +521,7 @@ async function runClaudeTurn(input: {
let resultText = "";
let structuredOutput: unknown;
let usage: ProviderUsage = {};
let messageCount = 0;
const onAbort = (): void => {
stream.close();
@@ -481,6 +531,12 @@ async function runClaudeTurn(input: {
try {
for await (const message of stream as AsyncIterable<SDKMessage>) {
messageCount += 1;
input.runtime.claudeObservability.recordMessage({
context: traceContext,
message,
});
if (message.type !== "result") {
continue;
}
@@ -502,6 +558,12 @@ async function runClaudeTurn(input: {
costUsd: message.total_cost_usd,
};
}
} catch (error) {
input.runtime.claudeObservability.recordQueryError({
context: traceContext,
error,
});
throw error;
} finally {
input.actorInput.signal.removeEventListener("abort", onAbort);
stream.close();
@@ -512,9 +574,22 @@ async function runClaudeTurn(input: {
}
if (!resultText) {
throw new Error("Claude run completed without a final result.");
const error = new Error("Claude run completed without a final result.");
input.runtime.claudeObservability.recordQueryError({
context: traceContext,
error,
});
throw error;
}
input.runtime.claudeObservability.recordQueryCompleted({
context: traceContext,
data: {
messageCount,
usage: toProviderUsageJson(usage),
},
});
return {
text: resultText,
structuredOutput,
@@ -554,19 +629,29 @@ export async function createProviderRunRuntime(input: {
initialPrompt: string;
config: Readonly<AppConfig>;
projectPath: string;
observabilityRootPath?: string;
}): Promise<ProviderRunRuntime> {
const sessionContext = await createSessionContext(input.provider, {
prompt: input.initialPrompt,
config: input.config,
workspaceRoot: input.projectPath,
});
const claudeObservability = new ClaudeObservabilityLogger({
workspaceRoot: input.observabilityRootPath ?? input.projectPath,
config: input.config.provider.claudeObservability,
});
return {
provider: input.provider,
config: input.config,
sessionContext,
claudeObservability,
close: async () => {
await sessionContext.close();
try {
await sessionContext.close();
} finally {
await claudeObservability.close();
}
},
};
}

View File

@@ -39,6 +39,9 @@ const dom = {
eventsLimit: document.querySelector("#events-limit"),
eventsRefresh: document.querySelector("#events-refresh"),
eventFeed: document.querySelector("#event-feed"),
claudeEventsLimit: document.querySelector("#claude-events-limit"),
claudeEventsRefresh: document.querySelector("#claude-events-refresh"),
claudeEventFeed: document.querySelector("#claude-event-feed"),
historyRefresh: document.querySelector("#history-refresh"),
historyBody: document.querySelector("#history-body"),
notificationsForm: document.querySelector("#notifications-form"),
@@ -147,6 +150,7 @@ const LABEL_HELP_BY_CONTROL = Object.freeze({
"session-project-path": "Absolute project path used when creating an explicit managed session.",
"session-close-merge": "When enabled, close will merge the session base branch back into the project branch.",
"events-limit": "Set how many recent runtime events are loaded per refresh.",
"claude-events-limit": "Set how many Claude SDK trace records are loaded per refresh.",
"cfg-webhook-url": "Webhook endpoint that receives runtime event notifications.",
"cfg-webhook-severity": "Minimum severity level that triggers webhook notifications.",
"cfg-webhook-always": "Event types that should always notify, regardless of severity.",
@@ -1493,6 +1497,43 @@ function renderEventFeed(events) {
dom.eventFeed.innerHTML = rows || '<div class="event-row"><div class="event-time">-</div><div class="event-type">-</div><div>No runtime events.</div></div>';
}
function toClaudeRowSeverity(event) {
const stage = String(event?.stage || "");
const type = String(event?.sdkMessageType || "");
if (stage === "query.error") {
return "critical";
}
if (stage === "query.stderr" || (type === "result" && String(event?.sdkMessageSubtype || "").startsWith("error_"))) {
return "warning";
}
return "info";
}
function renderClaudeTraceFeed(events) {
const rows = [...events]
.reverse()
.map((event) => {
const ts = new Date(event.timestamp).toLocaleTimeString();
const stage = String(event.stage || "query.message");
const sdkMessageType = String(event.sdkMessageType || "");
const sdkMessageSubtype = String(event.sdkMessageSubtype || "");
const typeLabel = sdkMessageType
? `${stage}/${sdkMessageType}${sdkMessageSubtype ? `:${sdkMessageSubtype}` : ""}`
: stage;
const message = typeof event.message === "string" ? event.message : JSON.stringify(event.message || "");
return `
<div class="event-row ${escapeHtml(toClaudeRowSeverity(event))}">
<div class="event-time">${escapeHtml(ts)}</div>
<div class="event-type">${escapeHtml(typeLabel)}</div>
<div>${escapeHtml(message)}</div>
</div>
`;
})
.join("");
dom.claudeEventFeed.innerHTML = rows || '<div class="event-row"><div class="event-time">-</div><div class="event-type">-</div><div>No Claude trace events.</div></div>';
}
async function refreshEvents() {
const limit = Number(dom.eventsLimit.value || "150");
const params = new URLSearchParams({
@@ -1507,6 +1548,20 @@ async function refreshEvents() {
renderEventFeed(payload.events || []);
}
async function refreshClaudeTrace() {
const limit = Number(dom.claudeEventsLimit.value || "150");
const params = new URLSearchParams({
limit: String(limit),
});
if (state.selectedSessionId) {
params.set("sessionId", state.selectedSessionId);
}
const payload = await apiRequest(`/api/claude-trace?${params.toString()}`);
renderClaudeTraceFeed(payload.events || []);
}
async function startRun(event) {
event.preventDefault();
@@ -1581,6 +1636,7 @@ async function startRun(event) {
dom.sessionSelect.value = run.sessionId;
await refreshGraph();
await refreshEvents();
await refreshClaudeTrace();
} catch (error) {
showRunStatus(error instanceof Error ? error.message : String(error), true);
}
@@ -1601,6 +1657,7 @@ async function cancelActiveRun() {
await loadSessions();
await refreshGraph();
await refreshEvents();
await refreshClaudeTrace();
} catch (error) {
showRunStatus(error instanceof Error ? error.message : String(error), true);
}
@@ -1633,6 +1690,7 @@ async function createSessionFromUi() {
dom.sessionSelect.value = state.selectedSessionId;
await refreshGraph();
await refreshEvents();
await refreshClaudeTrace();
}
} catch (error) {
showRunStatus(error instanceof Error ? error.message : String(error), true);
@@ -1659,6 +1717,7 @@ async function closeSelectedSessionFromUi() {
await loadSessions();
await refreshGraph();
await refreshEvents();
await refreshClaudeTrace();
} catch (error) {
showRunStatus(error instanceof Error ? error.message : String(error), true);
}
@@ -1808,6 +1867,7 @@ function bindUiEvents() {
state.selectedSessionId = dom.sessionSelect.value;
await refreshGraph();
await refreshEvents();
await refreshClaudeTrace();
});
dom.graphManifestSelect.addEventListener("change", async () => {
@@ -1827,9 +1887,14 @@ function bindUiEvents() {
await refreshEvents();
});
dom.claudeEventsRefresh.addEventListener("click", async () => {
await refreshClaudeTrace();
});
dom.historyRefresh.addEventListener("click", async () => {
await loadSessions();
await refreshGraph();
await refreshClaudeTrace();
});
dom.runForm.addEventListener("submit", startRun);
@@ -1949,6 +2014,7 @@ async function refreshAll() {
await refreshGraph();
await refreshEvents();
await refreshClaudeTrace();
}
async function initialize() {
@@ -1979,6 +2045,10 @@ async function initialize() {
void refreshEvents();
}, 3000);
setInterval(() => {
void refreshClaudeTrace();
}, 3000);
setInterval(() => {
void refreshGraph();
}, 7000);

View File

@@ -130,6 +130,24 @@
<div id="event-feed" class="event-feed"></div>
</section>
<section class="panel claude-panel">
<div class="panel-head">
<h2>Claude Trace</h2>
<div class="panel-actions">
<label>
Limit
<select id="claude-events-limit">
<option value="80">80</option>
<option value="150" selected>150</option>
<option value="300">300</option>
</select>
</label>
<button id="claude-events-refresh" type="button">Refresh</button>
</div>
</div>
<div id="claude-event-feed" class="event-feed claude-event-feed"></div>
</section>
<section class="panel history-panel">
<div class="panel-head">
<h2>Run History</h2>

View File

@@ -79,7 +79,8 @@ p {
grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
grid-template-areas:
"graph side"
"feed history"
"feed claude"
"history history"
"config config";
}
@@ -129,6 +130,10 @@ p {
grid-area: history;
}
.claude-panel {
grid-area: claude;
}
.config-panel {
grid-area: config;
}
@@ -314,6 +319,14 @@ button.danger {
color: var(--critical);
}
.claude-event-feed .event-row {
grid-template-columns: 110px 150px 1fr;
}
.claude-event-feed .event-type {
font-size: 0.7rem;
}
.history-table {
width: 100%;
border-collapse: collapse;
@@ -485,6 +498,7 @@ button.danger {
"graph"
"side"
"feed"
"claude"
"history"
"config";
}

View File

@@ -488,6 +488,7 @@ export class UiRunService {
initialPrompt: input.prompt,
config,
projectPath: session?.baseWorkspacePath ?? this.workspaceRoot,
observabilityRootPath: this.workspaceRoot,
});
}

View File

@@ -6,6 +6,7 @@ import { buildSessionGraphInsight, buildSessionSummaries } from "./session-insig
import { UiConfigStore, type LimitSettings, type RuntimeNotificationSettings, type SecurityPolicySettings } from "./config-store.js";
import { ManifestStore } from "./manifest-store.js";
import { filterRuntimeEvents, readRuntimeEvents } from "./runtime-events-store.js";
import { filterClaudeTraceEvents, readClaudeTraceEvents } from "./claude-trace-store.js";
import { parseJsonBody, sendJson, methodNotAllowed, notFound, serveStaticFile } from "./http-utils.js";
import { readRunMetaBySession, UiRunService, type RunExecutionMode } from "./run-service.js";
import type { RunProvider } from "./provider-executor.js";
@@ -120,11 +121,13 @@ function ensureNonEmptyString(value: unknown, field: string): string {
async function readRuntimePaths(configStore: UiConfigStore, workspaceRoot: string): Promise<{
stateRoot: string;
runtimeEventLogPath: string;
claudeTraceLogPath: string;
}> {
const snapshot = await configStore.readSnapshot();
return {
stateRoot: resolve(workspaceRoot, snapshot.paths.stateRoot),
runtimeEventLogPath: resolve(workspaceRoot, snapshot.paths.runtimeEventLogPath),
claudeTraceLogPath: resolve(workspaceRoot, snapshot.paths.claudeTraceLogPath),
};
}
@@ -313,6 +316,27 @@ async function handleApiRequest(input: {
return true;
}
if (pathname === "/api/claude-trace") {
if (method !== "GET") {
methodNotAllowed(response);
return true;
}
const { claudeTraceLogPath } = await readRuntimePaths(configStore, workspaceRoot);
const limit = parseLimit(requestUrl.searchParams.get("limit"), 200);
const sessionId = requestUrl.searchParams.get("sessionId") ?? undefined;
const events = filterClaudeTraceEvents(await readClaudeTraceEvents(claudeTraceLogPath), {
...(sessionId ? { sessionId } : {}),
limit,
});
sendJson(response, 200, {
ok: true,
events,
});
return true;
}
if (pathname === "/api/sessions") {
if (method === "POST") {
const body = await parseJsonBody<CreateSessionRequest>(request);