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

@@ -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();
}
},
};
}