diff --git a/core/agent-tracing/claude.ts b/core/agent-tracing/claude.ts new file mode 100644 index 00000000..1d5a68d2 --- /dev/null +++ b/core/agent-tracing/claude.ts @@ -0,0 +1,2 @@ +export * from './index'; +export { ClaudeAgentTracer, TraceSession } from './src/ClaudeAgentTracer'; diff --git a/core/agent-tracing/index.ts b/core/agent-tracing/index.ts new file mode 100644 index 00000000..2b21fcb7 --- /dev/null +++ b/core/agent-tracing/index.ts @@ -0,0 +1,3 @@ +export * from './src/types'; +export { AbstractOssClient } from './src/AbstractOssClient'; +export { AbstractLogServiceClient } from './src/AbstractLogServiceClient'; diff --git a/core/agent-tracing/langgraph.ts b/core/agent-tracing/langgraph.ts new file mode 100644 index 00000000..283ef88d --- /dev/null +++ b/core/agent-tracing/langgraph.ts @@ -0,0 +1,2 @@ +export * from './index'; +export { LangGraphTracer } from './src/LangGraphTracer'; diff --git a/core/agent-tracing/package.json b/core/agent-tracing/package.json new file mode 100644 index 00000000..a7bbcf93 --- /dev/null +++ b/core/agent-tracing/package.json @@ -0,0 +1,91 @@ +{ + "name": "@eggjs/agent-tracing", + "version": "3.72.0", + "description": "Tracing support for AI agents (LangGraph, Claude Agent SDK)", + "keywords": [ + "agent", + "claude", + "egg", + "langchain", + "langgraph", + "tegg", + "tracing", + "typescript" + ], + "main": "dist/index.js", + "files": [ + "dist/**/*.js", + "dist/**/*.d.ts" + ], + "typings": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./claude": { + "types": "./dist/claude.d.ts", + "default": "./dist/claude.js" + }, + "./langgraph": { + "types": "./dist/langgraph.d.ts", + "default": "./dist/langgraph.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "test": "node --eval \"process.exit(parseInt(process.versions.node) < 18 ? 0 : 1)\" || cross-env NODE_ENV=test NODE_OPTIONS='--no-deprecation' mocha", + "clean": "tsc -b --clean", + "tsc": "ut run clean && tsc -p ./tsconfig.json", + "tsc:pub": "ut run clean && tsc -p ./tsconfig.pub.json", + "prepublishOnly": "ut tsc:pub" + }, + "homepage": "https://github.com/eggjs/tegg", + "bugs": { + "url": "https://github.com/eggjs/tegg/issues" + }, + "repository": { + "type": "git", + "url": "git@github.com:eggjs/tegg.git", + "directory": "core/agent-tracing" + }, + "engines": { + "node": ">=18.0.0" + }, + "author": "killagu ", + "license": "MIT", + "dependencies": { + "@eggjs/tegg-background-task": "^3.72.0", + "@eggjs/core-decorator": "^3.72.0", + "@eggjs/tegg-types": "^3.72.0", + "onelogger": "^1.0.1" + }, + "peerDependencies": { + "@anthropic-ai/claude-agent-sdk": ">=0.2.52", + "@langchain/core": ">=1.1.1" + }, + "peerDependenciesMeta": { + "@anthropic-ai/claude-agent-sdk": { + "optional": true + }, + "@langchain/core": { + "optional": true + } + }, + "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.52", + "@anthropic-ai/sdk": "^0.78.0", + "@eggjs/tegg-common-util": "^3.72.0", + "@langchain/core": "^1.1.29", + "@langchain/langgraph": "^0.2.74", + "@types/mocha": "^10.0.1", + "@types/node": "^20.2.4", + "cross-env": "^7.0.3", + "mocha": "^10.2.0", + "ts-node": "^10.9.1", + "typescript": "^5.0.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/core/agent-tracing/src/AbstractLogServiceClient.ts b/core/agent-tracing/src/AbstractLogServiceClient.ts new file mode 100644 index 00000000..5b3fb4de --- /dev/null +++ b/core/agent-tracing/src/AbstractLogServiceClient.ts @@ -0,0 +1,31 @@ +/** + * Abstract log service client for dependency injection. + * + * To enable log service syncing in TracingService, implement this class in your application + * and register it with Tegg IoC. The implementation class MUST be named `LogServiceClient` + * (or use `@SingletonProto({ name: 'logServiceClient' })`) so the container can resolve it. + * + * @example + * ```typescript + * import { SingletonProto } from '@eggjs/core-decorator'; + * import { AccessLevel } from '@eggjs/tegg-types'; + * import { AbstractLogServiceClient } from '@eggjs/agent-tracing'; + * + * // Class name must be LogServiceClient (registers as 'logServiceClient' in the IoC container) + * @SingletonProto({ accessLevel: AccessLevel.PUBLIC }) + * export class LogServiceClient extends AbstractLogServiceClient { + * async send(log: string): Promise { + * await fetch('https://log.example.com/api', { + * method: 'POST', + * headers: { 'content-type': 'application/json' }, + * body: JSON.stringify({ log }), + * }); + * } + * } + * ``` + * + * If no implementation is registered, log service syncing is silently skipped. + */ +export abstract class AbstractLogServiceClient { + abstract send(log: string): Promise; +} diff --git a/core/agent-tracing/src/AbstractOssClient.ts b/core/agent-tracing/src/AbstractOssClient.ts new file mode 100644 index 00000000..f0ea395a --- /dev/null +++ b/core/agent-tracing/src/AbstractOssClient.ts @@ -0,0 +1,27 @@ +/** + * Abstract OSS client for dependency injection. + * + * To enable OSS uploads in TracingService, implement this class in your application + * and register it with Tegg IoC. The implementation class MUST be named `OssClient` + * (or use `@SingletonProto({ name: 'ossClient' })`) so the container can resolve it. + * + * @example + * ```typescript + * import { SingletonProto } from '@eggjs/core-decorator'; + * import { AccessLevel } from '@eggjs/tegg-types'; + * import { AbstractOssClient } from '@eggjs/agent-tracing'; + * + * // Class name must be OssClient (registers as 'ossClient' in the IoC container) + * @SingletonProto({ accessLevel: AccessLevel.PUBLIC }) + * export class OssClient extends AbstractOssClient { + * async put(key: string, content: string | Buffer): Promise { + * // your OSS implementation here + * } + * } + * ``` + * + * If no implementation is registered, OSS uploads are silently skipped. + */ +export abstract class AbstractOssClient { + abstract put(key: string, content: string | Buffer): Promise; +} diff --git a/core/agent-tracing/src/ClaudeAgentTracer.ts b/core/agent-tracing/src/ClaudeAgentTracer.ts new file mode 100644 index 00000000..bc5be88c --- /dev/null +++ b/core/agent-tracing/src/ClaudeAgentTracer.ts @@ -0,0 +1,515 @@ +import { randomUUID } from 'node:crypto'; + +import type { SDKMessage, SDKResultMessage } from '@anthropic-ai/claude-agent-sdk'; +import { SingletonProto, Inject } from '@eggjs/core-decorator'; +import { AccessLevel } from '@eggjs/tegg-types'; +import type { Logger } from '@eggjs/tegg-types'; +import type { Run } from '@langchain/core/tracers/base'; + +import type { TracingService } from './TracingService'; +import { + type ClaudeMessage, + type ClaudeContentBlock, + type ClaudeTokenUsage, + type IRunCost, + RunStatus, + type TracerConfig, + applyTracerConfig, +} from './types'; + +/** + * TraceSession - Manages state for a single agent execution with streaming support. + * Allows processing messages one-by-one and logging them immediately. + */ +export class TraceSession { + private traceId: string; + private rootRun: Run | null = null; + private rootRunId: string; + private startTime: number; + private executionOrder = 2; // Start at 2, root is 1 + private pendingToolUses = new Map(); + private tracer: ClaudeAgentTracer; + + constructor(tracer: ClaudeAgentTracer, sessionId?: string) { + this.tracer = tracer; + this.traceId = sessionId || randomUUID(); + this.rootRunId = randomUUID(); + this.startTime = Date.now(); + } + + /** + * Process a single SDK message and log it immediately. + * Non-tracing message types (tool_progress, stream_event, status, etc.) are automatically ignored. + */ + async processMessage(message: SDKMessage): Promise { + try { + const converted = this.tracer.convertSDKMessage(message); + if (!converted) return; + + if (converted.type === 'system' && converted.subtype === 'init') { + this.handleInit(converted); + } else if (converted.type === 'assistant') { + this.handleAssistant(converted); + } else if (converted.type === 'user') { + this.handleUser(converted); + } else if (converted.type === 'result') { + this.handleResult(converted); + } + } catch (e) { + this.tracer.logger.warn('[ClaudeAgentTracer] processMessage error:', e); + } + } + + private handleInit(message: ClaudeMessage): void { + this.traceId = message.session_id || this.traceId; + this.rootRun = this.tracer.createRootRunInternal(message, this.startTime, this.traceId, this.rootRunId); + this.tracer.logTrace(this.rootRun, RunStatus.START); + } + + private handleAssistant(message: ClaudeMessage): void { + if (!this.rootRun) { + this.tracer.logger.warn('[ClaudeAgentTracer] Received assistant message before init'); + return; + } + + const content = message.message?.content || []; + const hasToolUse = content.some(c => c.type === 'tool_use'); + const hasText = content.some(c => c.type === 'text'); + + if (hasToolUse) { + const eventTime = Date.now(); + // Create LLM run that initiated tool calls + const llmRun = this.tracer.createLLMRunInternal( + message, + this.rootRunId, + this.traceId, + this.executionOrder++, + eventTime, + true, + ); + this.rootRun.child_runs.push(llmRun); + this.tracer.logTrace(llmRun, RunStatus.END); + + // Create tool runs (will be completed when tool_result arrives) + for (const block of content) { + if (block.type === 'tool_use') { + const toolRun = this.tracer.createToolRunStartInternal( + block, + this.rootRunId, + this.traceId, + this.executionOrder++, + eventTime, + ); + this.rootRun.child_runs.push(toolRun); + this.pendingToolUses.set(block.id, toolRun); + this.tracer.logTrace(toolRun, RunStatus.START); + } + } + } else if (hasText) { + // Text-only response + const llmRun = this.tracer.createLLMRunInternal( + message, + this.rootRunId, + this.traceId, + this.executionOrder++, + Date.now(), + false, + ); + this.rootRun.child_runs.push(llmRun); + this.tracer.logTrace(llmRun, RunStatus.END); + } + } + + private handleUser(message: ClaudeMessage): void { + if (!message.message?.content) return; + + for (const block of message.message.content) { + if (block.type === 'tool_result') { + const toolRun = this.pendingToolUses.get(block.tool_use_id); + if (toolRun) { + this.tracer.completeToolRunInternal(toolRun, block, Date.now()); + const status = block.is_error ? RunStatus.ERROR : RunStatus.END; + this.tracer.logTrace(toolRun, status); + this.pendingToolUses.delete(block.tool_use_id); + } + } + } + } + + private handleResult(message: ClaudeMessage): void { + if (!this.rootRun) { + this.tracer.logger.warn('[ClaudeAgentTracer] Received result message before init'); + return; + } + + // Complete any pending tool runs + for (const [ toolUseId, toolRun ] of this.pendingToolUses) { + this.tracer.logger.warn(`[ClaudeAgentTracer] Tool run ${toolUseId} did not receive result`); + toolRun.end_time = Date.now(); + this.tracer.logTrace(toolRun, RunStatus.ERROR); + } + this.pendingToolUses.clear(); + + // Update and log root run end + this.rootRun.end_time = this.startTime + (message.duration_ms || 0); + this.rootRun.outputs = { + result: message.result, + is_error: message.is_error, + num_turns: message.num_turns, + }; + + if (message.usage || message.modelUsage) { + const cost = this.tracer.createRunCostInternal(message); + if (this.rootRun.outputs) { + (this.rootRun.outputs as any).llmOutput = cost; + } + } + + if (message.is_error) { + this.rootRun.error = message.result; + } + + this.rootRun.child_execution_order = this.executionOrder - 1; + const status = message.is_error ? RunStatus.ERROR : RunStatus.END; + this.tracer.logTrace(this.rootRun, status); + } + + /** + * Get current trace ID + */ + getTraceId(): string { + return this.traceId; + } +} + +/** + * ClaudeAgentTracer - Converts Claude SDK messages to LangChain Run format + * and logs them to the same remote logging system as LangGraphTracer. + * + * Supports both batch processing (processMessages) and streaming (createSession). + */ +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class ClaudeAgentTracer { + /** @internal */ + @Inject() + readonly logger: Logger; + + @Inject() + private tracingService: TracingService; + + name = 'ClaudeAgentTracer'; + agentName = ''; + + /** + * Configure the tracer with agent name and service credentials. + */ + configure(config: TracerConfig): void { + applyTracerConfig(this, config); + } + + /** + * Create a new trace session for streaming message processing. + * Use this for real-time tracing where messages arrive one-by-one. + * + * @example + * const session = claudeTracer.createSession(); + * for await (const message of agent.run('task')) { + * await session.processMessage(message); + * } + */ + public createSession(sessionId?: string): TraceSession { + return new TraceSession(this, sessionId); + } + + /** + * Main entry point - convert SDK messages to Run trees and log them. + * Use this when you have all messages collected (batch processing). + * For real-time streaming, use createSession() instead. + * + * Non-tracing message types (tool_progress, stream_event, status, etc.) are automatically filtered out. + */ + public async processMessages(sdkMessages: SDKMessage[]): Promise { + try { + if (!sdkMessages || sdkMessages.length === 0) { + this.logger.warn('[ClaudeAgentTracer] No messages to process'); + return; + } + + // Pre-validate: ensure there is an init message before creating session + const hasInit = sdkMessages.some(m => m.type === 'system' && 'subtype' in m && m.subtype === 'init'); + if (!hasInit) { + this.logger.warn('[ClaudeAgentTracer] No system/init message found'); + return; + } + + // Delegate to TraceSession for message processing + const session = this.createSession(); + for (const msg of sdkMessages) { + await session.processMessage(msg); + } + } catch (e) { + this.logger.warn('[ClaudeAgentTracer] processMessages error:', e); + } + } + + /** + * @internal + * Convert an SDKMessage to internal ClaudeMessage format. + * Returns null for message types that are not relevant to tracing. + */ + convertSDKMessage(msg: SDKMessage): ClaudeMessage | null { + // SDKSystemMessage (init) + if (msg.type === 'system' && 'subtype' in msg && msg.subtype === 'init') { + return msg as unknown as ClaudeMessage; + } + + // SDKAssistantMessage + if (msg.type === 'assistant' && 'message' in msg && 'parent_tool_use_id' in msg) { + return { + type: 'assistant', + uuid: msg.uuid, + session_id: msg.session_id, + message: msg.message as any, + parent_tool_use_id: msg.parent_tool_use_id, + }; + } + + // SDKUserMessage (tool results, not replay) + if (msg.type === 'user' && 'message' in msg && !('isReplay' in msg && (msg as any).isReplay)) { + return { + type: 'user', + uuid: msg.uuid || randomUUID(), + session_id: msg.session_id, + message: msg.message as any, + parent_tool_use_id: (msg as any).parent_tool_use_id, + }; + } + + // SDKResultMessage (success or error) + if (msg.type === 'result') { + const resultMsg = msg as SDKResultMessage; + const isSuccess = resultMsg.subtype === 'success'; + return { + type: 'result', + subtype: isSuccess ? 'success' : 'error', + is_error: resultMsg.is_error, + duration_ms: resultMsg.duration_ms, + duration_api_ms: resultMsg.duration_api_ms, + num_turns: resultMsg.num_turns, + result: isSuccess ? (resultMsg as any).result : (resultMsg as any).errors?.join('; ') || 'Unknown error', + session_id: resultMsg.session_id, + total_cost_usd: resultMsg.total_cost_usd, + usage: resultMsg.usage as any, + modelUsage: resultMsg.modelUsage as any, + uuid: resultMsg.uuid, + }; + } + + // Ignore all other SDK message types (tool_progress, stream_event, status, hook, etc.) + return null; + } + + /** + * @internal + * Create root run from init message (used by TraceSession) + */ + createRootRunInternal(initMsg: ClaudeMessage, startTime: number, traceId: string, rootRunId?: string): Run { + const runId = rootRunId || initMsg.uuid || randomUUID(); + + return { + id: runId, + name: this.name, + run_type: 'chain', + inputs: { + tools: initMsg.tools || [], + model: initMsg.model, + session_id: initMsg.session_id, + mcp_servers: initMsg.mcp_servers, + agents: initMsg.agents, + slash_commands: initMsg.slash_commands, + }, + outputs: undefined, + start_time: startTime, + end_time: undefined, + execution_order: 1, + child_execution_order: 1, + child_runs: [], + events: [], + trace_id: traceId, + parent_run_id: undefined, + tags: [], + extra: { + metadata: { + thread_id: initMsg.session_id, + }, + apiKeySource: initMsg.apiKeySource, + claude_code_version: initMsg.claude_code_version, + output_style: initMsg.output_style, + permissionMode: initMsg.permissionMode, + }, + } as Run; + } + + /** + * @internal + * Create LLM run from assistant message (used by TraceSession) + */ + createLLMRunInternal( + msg: ClaudeMessage, + rootRunId: string, + traceId: string, + order: number, + startTime: number, + isToolCall: boolean, + ): Run { + const runId = msg.uuid || randomUUID(); + const content = msg.message?.content || []; + + const textBlocks = content.filter(c => c.type === 'text'); + const toolBlocks = content.filter(c => c.type === 'tool_use'); + + const inputs = { + messages: textBlocks.map(c => (c as any).text).filter(Boolean), + }; + + const outputs: any = {}; + if (isToolCall) { + outputs.tool_calls = toolBlocks.map(c => ({ + id: (c as any).id, + name: (c as any).name, + input: (c as any).input, + })); + } else { + outputs.content = textBlocks.map(c => (c as any).text).join(''); + } + + if (msg.message?.usage) { + outputs.llmOutput = this.extractTokenUsage(msg.message.usage); + } + + return { + id: runId, + name: 'LLM', + run_type: 'llm', + inputs, + outputs, + start_time: startTime, + end_time: startTime, + execution_order: order, + child_execution_order: order, + child_runs: [], + events: [], + trace_id: traceId, + parent_run_id: rootRunId, + tags: [], + extra: { + model: msg.message?.model, + }, + } as Run; + } + + /** + * @internal + * Create tool run at start (before result, used by TraceSession) + */ + createToolRunStartInternal( + toolUseBlock: ClaudeContentBlock, + rootRunId: string, + traceId: string, + order: number, + startTime: number, + ): Run { + const toolUse = toolUseBlock as any; + const runId = randomUUID(); + + return { + id: runId, + name: toolUse.name || 'Tool', + run_type: 'tool', + inputs: { + tool_use_id: toolUse.id, + ...toolUse.input, + }, + outputs: undefined, + start_time: startTime, + end_time: undefined, + execution_order: order, + child_execution_order: order, + child_runs: [], + events: [], + trace_id: traceId, + parent_run_id: rootRunId, + tags: [], + extra: { + tool_use_id: toolUse.id, + }, + } as Run; + } + + /** + * @internal + * Complete tool run with result (used by TraceSession) + */ + completeToolRunInternal(toolRun: Run, toolResultBlock: ClaudeContentBlock, startTime: number): void { + const result = toolResultBlock as any; + toolRun.end_time = startTime; + toolRun.outputs = { + content: result.content, + }; + + if (result.is_error) { + toolRun.error = typeof result.content === 'string' ? result.content : JSON.stringify(result.content); + } + } + + /** + * Extract token usage from Claude SDK usage object into IRunCost format. + */ + private extractTokenUsage(usage: ClaudeTokenUsage): IRunCost { + const result: IRunCost = {}; + + if (usage.input_tokens !== undefined) { + result.promptTokens = usage.input_tokens; + } + if (usage.output_tokens !== undefined) { + result.completionTokens = usage.output_tokens; + } + if (usage.cache_creation_input_tokens !== undefined) { + result.cacheCreationInputTokens = usage.cache_creation_input_tokens; + } + if (usage.cache_read_input_tokens !== undefined) { + result.cacheReadInputTokens = usage.cache_read_input_tokens; + } + + const totalTokens = (usage.input_tokens || 0) + (usage.output_tokens || 0); + if (totalTokens > 0) { + result.totalTokens = totalTokens; + } + + return result; + } + + /** + * @internal + * Create run cost from result message (used by TraceSession) + */ + createRunCostInternal(resultMsg: ClaudeMessage): IRunCost { + const cost: IRunCost = resultMsg.usage ? this.extractTokenUsage(resultMsg.usage) : {}; + + if (resultMsg.total_cost_usd !== undefined) { + cost.totalCost = resultMsg.total_cost_usd; + } + + return cost; + } + + /** + * @internal + * Log trace - delegates to TracingService (used by TraceSession) + */ + logTrace(run: Run, status: RunStatus): void { + this.tracingService.logTrace(run, status, this.name, this.agentName); + } +} diff --git a/core/agent-tracing/src/LangGraphTracer.ts b/core/agent-tracing/src/LangGraphTracer.ts new file mode 100644 index 00000000..7ea76b68 --- /dev/null +++ b/core/agent-tracing/src/LangGraphTracer.ts @@ -0,0 +1,83 @@ +import { SingletonProto, Inject } from '@eggjs/core-decorator'; +import { AccessLevel } from '@eggjs/tegg-types'; +import { BaseTracer } from '@langchain/core/tracers/base'; +import type { Run } from '@langchain/core/tracers/base'; + +import type { TracingService } from './TracingService'; +import { RunStatus, type TracerConfig, applyTracerConfig } from './types'; + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class LangGraphTracer extends BaseTracer { + @Inject() + private tracingService: TracingService; + + name = 'LangGraphTracer'; + + agentName = ''; + + /** + * Configure the tracer with agent name and service credentials. + */ + configure(config: TracerConfig): void { + applyTracerConfig(this, config); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected persistRun(_: Run): Promise { + return Promise.resolve(undefined); + } + + private logTrace(run: Run, status: RunStatus): void { + this.tracingService.logTrace(run, status, this.name, this.agentName); + } + + onChainStart(run: Run): void | Promise { + this.logTrace(run, RunStatus.START); + } + onChainEnd(run: Run): void | Promise { + this.logTrace(run, RunStatus.END); + } + onChainError(run: Run): void | Promise { + this.logTrace(run, RunStatus.ERROR); + } + + onToolStart(run: Run): void | Promise { + this.logTrace(run, RunStatus.START); + } + onToolEnd(run: Run): void | Promise { + this.logTrace(run, RunStatus.END); + } + + onToolError(run: Run): void | Promise { + this.logTrace(run, RunStatus.ERROR); + } + + onLLMStart(run: Run): void | Promise { + this.logTrace(run, RunStatus.START); + } + onLLMEnd(run: Run): void | Promise { + this.logTrace(run, RunStatus.END); + } + onLLMError(run: Run): void | Promise { + this.logTrace(run, RunStatus.ERROR); + } + + onRetrieverStart(run: Run): void | Promise { + this.logTrace(run, RunStatus.START); + } + onRetrieverEnd(run: Run): void | Promise { + this.logTrace(run, RunStatus.END); + } + onRetrieverError(run: Run): void | Promise { + this.logTrace(run, RunStatus.ERROR); + } + + onAgentAction(run: Run): void | Promise { + this.logTrace(run, RunStatus.START); + } + onAgentEnd(run: Run): void | Promise { + this.logTrace(run, RunStatus.END); + } +} diff --git a/core/agent-tracing/src/TracingService.ts b/core/agent-tracing/src/TracingService.ts new file mode 100644 index 00000000..9adcecad --- /dev/null +++ b/core/agent-tracing/src/TracingService.ts @@ -0,0 +1,156 @@ +import type { BackgroundTaskHelper } from '@eggjs/tegg-background-task'; +import { SingletonProto, Inject, InjectOptional } from '@eggjs/core-decorator'; +import { AccessLevel } from '@eggjs/tegg-types'; +import type { Logger } from '@eggjs/tegg-types'; +import type { Run } from '@langchain/core/tracers/base'; +import { getCustomLogger } from 'onelogger'; + +import { AbstractLogServiceClient } from './AbstractLogServiceClient'; +import { AbstractOssClient } from './AbstractOssClient'; +import { FIELDS_TO_OSS, type IResource, RunStatus } from './types'; + +/** + * TracingService - Shared service for common tracing operations. + * Used by both LangGraphTracer and ClaudeAgentTracer to avoid code duplication. + */ +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class TracingService { + @Inject() + public readonly logger: Logger; + + @Inject() + private backgroundTaskHelper: BackgroundTaskHelper; + + @InjectOptional() + private readonly ossClient: AbstractOssClient; + + @InjectOptional() + private readonly logServiceClient: AbstractLogServiceClient; + + /** + * Get the current environment (local, pre, prod, gray) + */ + getEnv(): string { + const env = process.env.FAAS_ENV || process.env.SERVER_ENV || 'local'; + if (env === 'prepub') { + return 'pre'; + } + return env; + } + + /** + * Check if running in online environment (prod, pre, gray) + */ + isOnlineEnv(): boolean { + const env = this.getEnv(); + return [ 'prod', 'pre', 'gray' ].includes(env); + } + + /** + * Generate log info prefix for a run + */ + getLogInfoPrefix(run: Run, status: RunStatus, name: string): string { + const env = this.getEnv(); + const envSegment = process.env.FAAS_ENV || env === 'local' ? '' : `env=${env},`; + const threadId = (run.extra as Record)?.metadata?.thread_id ?? 'unknown'; + return ( + `[agent_run][${name}]:` + + `traceId=${run.trace_id},` + + `threadId=${threadId},` + + `type=${run.parent_run_id ? 'child_run' : 'root_run'},` + + `status=${status},` + + `${envSegment}` + + `run_id=${run.id},` + + `parent_run_id=${run.parent_run_id ?? ''}` + ); + } + + /** + * Upload content to OSS using the injected AbstractOssClient implementation. + * Gracefully skips if no AbstractOssClient is provided. + */ + async uploadToOss(key: string, fileContent: string): Promise { + if (!this.ossClient) { + this.logger.warn('[TracingService] OSS client not configured. Provide an AbstractOssClient implementation.'); + return; + } + this.logger.info(`Uploading to OSS with key: ${key}`); + await this.ossClient.put(key, Buffer.from(fileContent)); + this.logger.info(`Upload completed for key: ${key}`); + } + + /** + * Sync local tracing logs to the injected AbstractLogServiceClient implementation. + * Silently skips if no AbstractLogServiceClient is registered. + */ + async syncLocalToLogService(log: string, agentName: string): Promise { + if (!this.logServiceClient) { + return; + } + if (!agentName) { + this.logger.warn('[TraceLogErr] syncLocalToLogService: agentName is empty'); + return; + } + try { + await this.logServiceClient.send(`[${agentName}]${log}`); + } catch (e) { + this.logger.warn('[TraceLogErr] syncLocalToLogService error:', e); + } + } + + /** + * Log trace run data with OSS upload for large fields + */ + logTrace(run: Run, status: RunStatus, name: string, agentName: string): void { + try { + const { child_runs: childs, ...runData } = run; + if (runData.tags?.includes('langsmith:hidden')) { + return; + } + + const env = this.getEnv(); + FIELDS_TO_OSS.forEach(field => { + if (!runData[field]) { + return; + } + const jsonstr = JSON.stringify(runData[field]); + if (field === 'outputs') { + (runData as any).cost = runData?.outputs?.llmOutput; + } + delete runData[field]; + const key = `agents/${name}/${env}/traces/${run.trace_id}/runs/${run.id}/${field}`; + this.backgroundTaskHelper.run(async () => { + try { + await this.uploadToOss(key, jsonstr); + } catch (e) { + this.logger.warn( + `[TraceLogErr] Failed to upload run data to OSS for run_id=${run.id}, field=${field}, error:`, + e, + ); + } + }); + (runData as any)[field] = { compress: 'none', key } as IResource; + }); + + const runJSON = JSON.stringify({ ...runData, child_run_ids: childs?.map(child => child.id) }); + const logInfo = this.getLogInfoPrefix(run, status, name) + `,run=${runJSON}`; + + if (process.env.FAAS_ENV) { + this.logger.info(logInfo); + } else { + const logger = getCustomLogger('agentTraceLogger') || this.logger; + logger.info(`[${agentName}]${logInfo}`); + } + + if (env === 'local') { + this.backgroundTaskHelper.run(async () => { + await this.syncLocalToLogService(logInfo, agentName); + }); + } + } catch (e) { + this.logger.warn('[TraceLogErr] logTrace error:', e); + } + } +} diff --git a/core/agent-tracing/src/types.ts b/core/agent-tracing/src/types.ts new file mode 100644 index 00000000..6416b487 --- /dev/null +++ b/core/agent-tracing/src/types.ts @@ -0,0 +1,147 @@ +// Claude SDK Message Types + +export interface ClaudeTextContent { + type: 'text'; + text: string; +} + +export interface ClaudeToolUseContent { + type: 'tool_use'; + id: string; + name: string; + input?: Record; +} + +export interface ClaudeToolResultContent { + type: 'tool_result'; + content: string | ClaudeToolResultContent[]; + tool_use_id: string; + is_error?: boolean; +} + +export type ClaudeContentBlock = ClaudeTextContent | ClaudeToolUseContent | ClaudeToolResultContent; + +export interface ClaudeTokenUsage { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + server_tool_use?: { + web_search_requests?: number; + web_fetch_requests?: number; + }; + service_tier?: string; + cache_creation?: { + ephemeral_1h_input_tokens?: number; + ephemeral_5m_input_tokens?: number; + }; +} + +export interface ClaudeMessageContent { + id?: string; + type?: string; + role?: string; + content?: ClaudeContentBlock[]; + model?: string; + usage?: ClaudeTokenUsage; + context_management?: any; + stop_reason?: string; + stop_sequence?: string; + container?: any; + [key: string]: any; +} + +export interface ClaudeModelUsage { + [modelName: string]: { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + webSearchRequests?: number; + costUSD?: number; + contextWindow?: number; + maxOutputTokens?: number; + }; +} + +export interface ClaudeMessage { + type: 'system' | 'assistant' | 'user' | 'result'; + subtype?: 'init' | 'success' | 'error'; + session_id?: string; + uuid: string; + message?: ClaudeMessageContent; + + // System/init message fields + cwd?: string; + tools?: string[]; + mcp_servers?: Array<{ name: string; status: string }>; + model?: string; + permissionMode?: string; + slash_commands?: string[]; + apiKeySource?: string; + claude_code_version?: string; + output_style?: string; + agents?: string[]; + skills?: any[]; + plugins?: any[]; + + // Result message fields + is_error?: boolean; + duration_ms?: number; + duration_api_ms?: number; + num_turns?: number; + result?: string; + total_cost_usd?: number; + usage?: ClaudeTokenUsage; + modelUsage?: ClaudeModelUsage; + permission_denials?: any[]; + + // User message fields (tool results) + parent_tool_use_id?: string | null; + tool_use_id?: string; + tool_use_result?: string; + + // Error fields + error?: string; + + [key: string]: any; +} + +// Shared tracing types (from LangGraphTracer) + +export interface IResource { + compress: 'none' | 'gzip'; + key: string; +} + +export interface IRunCost { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + totalCost?: number; +} + +const FIELDS_TO_OSS = [ 'inputs', 'outputs', 'attachments', 'serialized', 'events' ] as const; + +export const RunStatus = { + START: 'start', + END: 'end', + ERROR: 'error', +} as const; +export type RunStatus = (typeof RunStatus)[keyof typeof RunStatus]; + +/** User-facing config passed to tracer.configure() */ +export interface TracerConfig { + agentName?: string; +} + +/** Apply user-facing TracerConfig to a tracer instance. */ +export function applyTracerConfig(tracer: { agentName: string }, config: TracerConfig): void { + if (config.agentName !== undefined) { + tracer.agentName = config.agentName; + } +} + +export { FIELDS_TO_OSS }; diff --git a/core/agent-tracing/test/ClaudeAgentTracer.test.ts b/core/agent-tracing/test/ClaudeAgentTracer.test.ts new file mode 100644 index 00000000..7f6ca186 --- /dev/null +++ b/core/agent-tracing/test/ClaudeAgentTracer.test.ts @@ -0,0 +1,435 @@ +import assert from 'node:assert/strict'; + +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; + +import { ClaudeAgentTracer } from '../src/ClaudeAgentTracer'; +import { RunStatus } from '../src/types'; +import { createMockLogger, createCapturingTracingService } from './TestUtils'; + +// ---------- Shared setup ---------- + +function createTestEnv() { + const { tracingService, capturedRuns } = createCapturingTracingService(); + const mockLogger = createMockLogger(); + + const claudeTracer = new ClaudeAgentTracer(); + (claudeTracer as any).logger = mockLogger; + (claudeTracer as any).tracingService = tracingService; + + return { claudeTracer, capturedRuns }; +} + +// ---------- Mock data factories ---------- + +function createMockInit(overrides?: Partial): SDKMessage { + return { + type: 'system', + subtype: 'init', + session_id: 'test-session-001', + uuid: 'uuid-init', + tools: [ 'Bash', 'Read' ], + model: 'claude-sonnet-4-5-20250929', + cwd: '/test', + mcp_servers: [], + permissionMode: 'default', + apiKeySource: 'api_key', + claude_code_version: '1.0.0', + output_style: 'text', + slash_commands: [], + skills: [], + plugins: [], + ...overrides, + } as unknown as SDKMessage; +} + +function createMockAssistantWithTool(overrides?: Partial): SDKMessage { + return { + type: 'assistant', + uuid: 'uuid-assistant-tool', + session_id: 'test-session-001', + parent_tool_use_id: null, + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me run that command for you.' }, + { type: 'tool_use', id: 'tu_1', name: 'Bash', input: { command: 'echo hello' } }, + ], + model: 'claude-sonnet-4-5-20250929', + usage: { input_tokens: 100, output_tokens: 50 }, + stop_reason: 'tool_use', + }, + ...overrides, + } as unknown as SDKMessage; +} + +function createMockUserToolResult(overrides?: Partial): SDKMessage { + return { + type: 'user', + uuid: 'uuid-user-result', + session_id: 'test-session-001', + parent_tool_use_id: null, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tu_1', + content: 'hello', + is_error: false, + }, + ], + }, + ...overrides, + } as unknown as SDKMessage; +} + +function createMockAssistantTextOnly(overrides?: Partial): SDKMessage { + return { + type: 'assistant', + uuid: 'uuid-assistant-text', + session_id: 'test-session-001', + parent_tool_use_id: null, + message: { + id: 'msg_2', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'The answer is 21.' }], + model: 'claude-sonnet-4-5-20250929', + usage: { input_tokens: 80, output_tokens: 30 }, + stop_reason: 'end_turn', + }, + ...overrides, + } as unknown as SDKMessage; +} + +function createMockResult(overrides?: Partial): SDKMessage { + return { + type: 'result', + subtype: 'success', + session_id: 'test-session-001', + uuid: 'uuid-result', + is_error: false, + duration_ms: 1500, + duration_api_ms: 1200, + num_turns: 1, + result: 'hello', + stop_reason: null, + total_cost_usd: 0.003, + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + ...overrides, + } as unknown as SDKMessage; +} + +// Noise messages that should be filtered out +function createMockToolProgress(): SDKMessage { + return { + type: 'tool_progress', + tool_use_id: 'tu_1', + tool_name: 'Bash', + parent_tool_use_id: null, + elapsed_time_seconds: 0.5, + uuid: 'uuid-progress', + session_id: 'test-session-001', + } as unknown as SDKMessage; +} + +function createMockStreamEvent(): SDKMessage { + return { + type: 'stream_event', + event: { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + parent_tool_use_id: null, + uuid: 'uuid-stream', + session_id: 'test-session-001', + } as unknown as SDKMessage; +} + +// ---------- Tests ---------- + +describe('test/ClaudeAgentTracer.test.ts', () => { + let originalFaasEnv: string | undefined; + + beforeEach(() => { + originalFaasEnv = process.env.FAAS_ENV; + process.env.FAAS_ENV = 'dev'; + }); + + afterEach(() => { + if (originalFaasEnv === undefined) { + delete process.env.FAAS_ENV; + } else { + process.env.FAAS_ENV = originalFaasEnv; + } + }); + + describe('Streaming mode + tool use', () => { + it('should trace tool execution with session.processMessage', async () => { + const { claudeTracer, capturedRuns } = createTestEnv(); + const session = claudeTracer.createSession(); + + // Feed messages one-by-one, including noise messages that should be filtered + const messages: SDKMessage[] = [ + createMockInit(), + createMockStreamEvent(), // noise — should be ignored + createMockAssistantWithTool(), + createMockToolProgress(), // noise — should be ignored + createMockUserToolResult(), + createMockResult(), + ]; + + for (const msg of messages) { + await session.processMessage(msg); + } + + // Root run start + end + const rootStart = capturedRuns.find(e => !e.run.parent_run_id && e.status === RunStatus.START); + assert(rootStart, 'Should have root_run start'); + assert.strictEqual(rootStart.run.run_type, 'chain'); + + const rootEnd = capturedRuns.find(e => !e.run.parent_run_id && e.status === RunStatus.END); + assert(rootEnd, 'Should have root_run end'); + + // LLM child run + const llmRuns = capturedRuns.filter(e => !!e.run.parent_run_id && e.run.run_type === 'llm'); + assert(llmRuns.length >= 1, `Should have >= 1 LLM run, got ${llmRuns.length}`); + + // Tool child run start + end + const toolRuns = capturedRuns.filter(e => !!e.run.parent_run_id && e.run.run_type === 'tool'); + assert(toolRuns.length >= 2, `Should have >= 2 tool run entries (start+end), got ${toolRuns.length}`); + + const toolStart = toolRuns.find(e => e.status === RunStatus.START); + assert(toolStart, 'Should have tool start'); + assert.strictEqual(toolStart.run.name, 'Bash'); + + const toolEnd = toolRuns.find(e => e.status === RunStatus.END); + assert(toolEnd, 'Should have tool end'); + + // All runs share the same trace_id = session_id + const traceIds = new Set(capturedRuns.map(e => e.run.trace_id)); + assert.strictEqual(traceIds.size, 1, `All runs should share one trace_id, got ${traceIds.size}`); + assert.strictEqual([ ...traceIds ][0], 'test-session-001', 'trace_id should match session_id'); + + // Root run should carry session_id as thread_id in extra.metadata + const rootExtra = rootStart.run.extra as Record; + assert.strictEqual(rootExtra?.metadata?.thread_id, 'test-session-001', 'thread_id should match session_id'); + + // Child runs reference root run as parent + const childEntries = capturedRuns.filter(e => !!e.run.parent_run_id); + for (const child of childEntries) { + assert.strictEqual( + child.run.parent_run_id, + rootStart.run.id, + `Child run ${child.run.name} should reference root as parent`, + ); + } + + // Cost data on root end + const llmOutput = (rootEnd.run.outputs as any)?.llmOutput; + assert(llmOutput, 'Root end should have llmOutput'); + assert.strictEqual(llmOutput.promptTokens, 100); + assert.strictEqual(llmOutput.completionTokens, 50); + assert.strictEqual(llmOutput.totalCost, 0.003); + }); + }); + + describe('Batch mode + text-only', () => { + it('should trace a text-only response via processMessages', async () => { + const { claudeTracer, capturedRuns } = createTestEnv(); + + const messages: SDKMessage[] = [ + createMockInit(), + createMockAssistantTextOnly(), + createMockResult({ + usage: { + input_tokens: 80, + output_tokens: 30, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + total_cost_usd: 0.002, + }), + ]; + + await claudeTracer.processMessages(messages); + + assert(capturedRuns.length > 0, 'Should have tracing entries'); + + // Root run start + end + const rootEntries = capturedRuns.filter(e => !e.run.parent_run_id); + assert(rootEntries.length >= 2, `Should have root start + end, got ${rootEntries.length}`); + + const rootEnd = rootEntries.find(e => e.status === RunStatus.END); + assert(rootEnd, 'Should have root end'); + + // LLM child run with text content + const llmRuns = capturedRuns.filter(e => !!e.run.parent_run_id && e.run.run_type === 'llm'); + assert(llmRuns.length >= 1, `Should have >= 1 LLM run, got ${llmRuns.length}`); + + // No tool runs + const toolRuns = capturedRuns.filter(e => !!e.run.parent_run_id && e.run.run_type === 'tool'); + assert.strictEqual(toolRuns.length, 0, 'Should have no tool runs for text-only'); + + // Cost and token counts + const llmOutput = (rootEnd.run.outputs as any)?.llmOutput; + assert(llmOutput, 'Should have llmOutput'); + assert.strictEqual(llmOutput.promptTokens, 80); + assert.strictEqual(llmOutput.completionTokens, 30); + assert.strictEqual(llmOutput.totalTokens, 110); + assert.strictEqual(llmOutput.totalCost, 0.002); + + // trace_id consistency + const traceIds = new Set(capturedRuns.map(e => e.run.trace_id)); + assert.strictEqual(traceIds.size, 1, 'All runs should share one trace_id'); + }); + }); + + describe('Error scenario', () => { + it('should trace an error result with ERROR status', async () => { + const { claudeTracer, capturedRuns } = createTestEnv(); + const session = claudeTracer.createSession(); + + const messages: SDKMessage[] = [ + createMockInit(), + createMockAssistantTextOnly(), + { + type: 'result', + subtype: 'error_during_execution', + session_id: 'test-session-001', + uuid: 'uuid-result-err', + is_error: true, + duration_ms: 500, + duration_api_ms: 400, + num_turns: 1, + stop_reason: null, + total_cost_usd: 0.001, + errors: [ 'Something went wrong', 'Another error' ], + usage: { + input_tokens: 50, + output_tokens: 10, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + } as unknown as SDKMessage, + ]; + + for (const msg of messages) { + await session.processMessage(msg); + } + + // Root run should end with ERROR status + const rootError = capturedRuns.find(e => !e.run.parent_run_id && e.status === RunStatus.ERROR); + assert(rootError, 'Should have root_run with error status'); + assert(rootError.run, 'Root error run should exist'); + }); + }); + + describe('Guard clauses — messages before init', () => { + it('should warn and ignore assistant message received before init', async () => { + const { claudeTracer, capturedRuns } = createTestEnv(); + const session = claudeTracer.createSession(); + + // Send assistant message without a preceding init + await session.processMessage(createMockAssistantWithTool()); + + // Nothing should have been traced + assert.strictEqual(capturedRuns.length, 0, 'No runs should be captured before init'); + }); + + it('should warn and ignore result message received before init', async () => { + const { claudeTracer, capturedRuns } = createTestEnv(); + const session = claudeTracer.createSession(); + + // Send result message without a preceding init + await session.processMessage(createMockResult()); + + // Nothing should have been traced + assert.strictEqual(capturedRuns.length, 0, 'No runs should be captured before init'); + }); + }); + + describe('Pending tool runs cleanup on result', () => { + it('should log ERROR for pending tool runs that never received a result', async () => { + const { claudeTracer, capturedRuns } = createTestEnv(); + const session = claudeTracer.createSession(); + + // Init → assistant calls a tool → result arrives WITHOUT the tool_result + const messages: SDKMessage[] = [ + createMockInit(), + createMockAssistantWithTool(), // creates a pending tool run (tu_1) + createMockResult(), // result arrives before tool_result + ]; + + for (const msg of messages) { + await session.processMessage(msg); + } + + // The pending tool run should have been force-closed with ERROR status + const toolErrors = capturedRuns.filter(e => e.run.run_type === 'tool' && e.status === RunStatus.ERROR); + assert(toolErrors.length >= 1, `Should have at least one tool ERROR entry, got ${toolErrors.length}`); + }); + }); + + describe('processMessages edge cases', () => { + it('should warn and return early for empty message array', async () => { + const { claudeTracer, capturedRuns } = createTestEnv(); + + await claudeTracer.processMessages([]); + + assert.strictEqual(capturedRuns.length, 0, 'No runs should be captured for empty input'); + }); + + it('should warn and return early when no init message is present', async () => { + const { claudeTracer, capturedRuns } = createTestEnv(); + + // Only assistant and result, no system/init + const messages: SDKMessage[] = [ createMockAssistantTextOnly(), createMockResult() ]; + + await claudeTracer.processMessages(messages); + + assert.strictEqual(capturedRuns.length, 0, 'No runs should be captured without an init message'); + }); + }); + + describe('Internal error handling', () => { + it('should catch errors thrown inside processMessage without propagating', async () => { + const { claudeTracer } = createTestEnv(); + const session = claudeTracer.createSession(); + + // Force an error by replacing logTrace with a throwing stub + (claudeTracer as any).tracingService = { + logTrace: () => { + throw new Error('unexpected logTrace error'); + }, + }; + + // Should NOT throw — error is swallowed by the catch block + await assert.doesNotReject(async () => { + await session.processMessage(createMockInit()); + }); + }); + + it('should catch errors thrown inside processMessages without propagating', async () => { + const { claudeTracer } = createTestEnv(); + + // Replace createSession with a throwing stub to trigger the outer catch + (claudeTracer as any).createSession = () => { + throw new Error('unexpected createSession error'); + }; + + // Should NOT throw — error is swallowed by the catch block + await assert.doesNotReject(async () => { + await claudeTracer.processMessages([ createMockInit() ]); + }); + }); + }); +}); diff --git a/core/agent-tracing/test/Configure.test.ts b/core/agent-tracing/test/Configure.test.ts new file mode 100644 index 00000000..6f5aee28 --- /dev/null +++ b/core/agent-tracing/test/Configure.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; + +import { ClaudeAgentTracer } from '../src/ClaudeAgentTracer'; +import { LangGraphTracer } from '../src/LangGraphTracer'; +import { createMockLogger, createCapturingTracingService } from './TestUtils'; + +describe('test/Configure.test.ts', () => { + describe('LangGraphTracer.configure()', () => { + it('should set agentName and delegate to TracingService', () => { + const { tracingService } = createCapturingTracingService(); + const tracer = new LangGraphTracer(); + (tracer as any).tracingService = tracingService; + + tracer.configure({ + agentName: 'MyAgent', + }); + + assert.strictEqual(tracer.agentName, 'MyAgent'); + assert.strictEqual(tracer.name, 'LangGraphTracer', 'name should remain default'); + }); + + it('should not change agentName when not provided', () => { + const { tracingService } = createCapturingTracingService(); + const tracer = new LangGraphTracer(); + (tracer as any).tracingService = tracingService; + tracer.agentName = 'existing'; + + tracer.configure({}); + + assert.strictEqual(tracer.agentName, 'existing'); + }); + }); + + describe('ClaudeAgentTracer.configure()', () => { + it('should set agentName and delegate to TracingService', () => { + const { tracingService } = createCapturingTracingService(); + const mockLogger = createMockLogger(); + const tracer = new ClaudeAgentTracer(); + (tracer as any).logger = mockLogger; + (tracer as any).tracingService = tracingService; + + tracer.configure({ + agentName: 'MyClaude', + }); + + assert.strictEqual(tracer.agentName, 'MyClaude'); + assert.strictEqual(tracer.name, 'ClaudeAgentTracer', 'name should remain default'); + }); + + it('should not change agentName when not provided', () => { + const { tracingService } = createCapturingTracingService(); + const mockLogger = createMockLogger(); + const tracer = new ClaudeAgentTracer(); + (tracer as any).logger = mockLogger; + (tracer as any).tracingService = tracingService; + tracer.agentName = 'existing'; + + tracer.configure({}); + + assert.strictEqual(tracer.agentName, 'existing'); + }); + }); +}); diff --git a/core/agent-tracing/test/LangGraphTracer.test.ts b/core/agent-tracing/test/LangGraphTracer.test.ts new file mode 100644 index 00000000..d433abf7 --- /dev/null +++ b/core/agent-tracing/test/LangGraphTracer.test.ts @@ -0,0 +1,315 @@ +import assert from 'node:assert/strict'; + +import type { Run } from '@langchain/core/tracers/base'; +import { FakeLLM } from '@langchain/core/utils/testing'; +import { StateGraph, Annotation, START, END } from '@langchain/langgraph'; + +import { LangGraphTracer } from '../src/LangGraphTracer'; +import { RunStatus } from '../src/types'; +import { type CapturedEntry, createCapturingTracingService, createMockRun } from './TestUtils'; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +/** Shared state schema for test graphs */ +const GraphState = Annotation.Root({ + query: Annotation, + result: Annotation, +}); + +describe('test/LangGraphTracer.test.ts', () => { + let tracer: LangGraphTracer; + let capturedRuns: CapturedEntry[]; + let originalFaasEnv: string | undefined; + + beforeEach(() => { + originalFaasEnv = process.env.FAAS_ENV; + process.env.FAAS_ENV = 'dev'; + + const capturing = createCapturingTracingService(); + capturedRuns = capturing.capturedRuns; + + tracer = new LangGraphTracer(); + (tracer as any).tracingService = capturing.tracingService; + }); + + afterEach(() => { + if (originalFaasEnv === undefined) { + delete process.env.FAAS_ENV; + } else { + process.env.FAAS_ENV = originalFaasEnv; + } + }); + + describe('Single-node StateGraph triggers chain lifecycle hooks', () => { + it('should trigger onChainStart and onChainEnd via graph.invoke', async () => { + const graph = new StateGraph(GraphState) + .addNode('process', (state: typeof GraphState.State) => { + return { result: `processed: ${state.query}` }; + }) + .addEdge(START, 'process') + .addEdge('process', END) + .compile(); + + await graph.invoke({ query: 'hello', result: '' }, { callbacks: [ tracer ] }); + + await sleep(500); + + const startEntries = capturedRuns.filter(e => e.status === RunStatus.START); + const endEntries = capturedRuns.filter(e => e.status === RunStatus.END); + + assert(startEntries.length >= 1, `Should have at least one start entry, got ${startEntries.length}`); + assert(endEntries.length >= 1, `Should have at least one end entry, got ${endEntries.length}`); + + // Verify a chain run is present with run_type=chain + const chainStart = startEntries.find(e => e.run.run_type === 'chain'); + assert(chainStart, 'Should have a chain start entry with run_type=chain'); + }); + + it('should produce Run with valid id, trace_id, and run_type fields', async () => { + const graph = new StateGraph(GraphState) + .addNode('echo', (state: typeof GraphState.State) => { + return { result: state.query }; + }) + .addEdge(START, 'echo') + .addEdge('echo', END) + .compile(); + + await graph.invoke({ query: 'test', result: '' }, { callbacks: [ tracer ] }); + + await sleep(500); + + assert(capturedRuns.length > 0, 'Should have captured runs'); + + for (const entry of capturedRuns) { + assert(entry.run.id, 'Run should have an id'); + assert(entry.run.trace_id, 'Run should have a trace_id'); + assert(entry.run.run_type, 'Run should have a run_type'); + } + }); + }); + + describe('Multi-node linear StateGraph triggers parent-child chain hooks', () => { + it('should generate root and child chain runs with parent_run_id relationship', async () => { + const graph = new StateGraph(GraphState) + .addNode('preprocess', (state: typeof GraphState.State) => { + return { query: state.query.toUpperCase() }; + }) + .addNode('respond', (state: typeof GraphState.State) => { + return { result: `answer to ${state.query}` }; + }) + .addEdge(START, 'preprocess') + .addEdge('preprocess', 'respond') + .addEdge('respond', END) + .compile(); + + await graph.invoke({ query: 'question', result: '' }, { callbacks: [ tracer ] }); + + await sleep(500); + + const chainEntries = capturedRuns.filter(e => e.run.run_type === 'chain'); + assert( + chainEntries.length >= 2, + `Should have at least 2 chain runs (root + child nodes), got ${chainEntries.length}`, + ); + + // The graph root run should have no parent_run_id + const rootRun = chainEntries.find(e => !e.run.parent_run_id); + assert(rootRun, 'Should have a root chain run (no parent_run_id)'); + + // Child node runs should have parent_run_id + const childRuns = chainEntries.filter(e => e.run.parent_run_id); + assert(childRuns.length >= 1, 'Should have at least one child chain run with parent_run_id'); + + // Verify root vs child distinction + const rootEntries = capturedRuns.filter(e => !e.run.parent_run_id); + const childEntries = capturedRuns.filter(e => !!e.run.parent_run_id); + assert(rootEntries.length >= 1, 'Should have root run entries'); + assert(childEntries.length >= 1, 'Should have child run entries'); + }); + + it('should share the same trace_id across all runs in a graph invocation', async () => { + const graph = new StateGraph(GraphState) + .addNode('step1', (state: typeof GraphState.State) => { + return { query: `[step1] ${state.query}` }; + }) + .addNode('step2', (state: typeof GraphState.State) => { + return { result: `[step2] ${state.query}` }; + }) + .addEdge(START, 'step1') + .addEdge('step1', 'step2') + .addEdge('step2', END) + .compile(); + + await graph.invoke({ query: 'hello', result: '' }, { callbacks: [ tracer ] }); + + await sleep(500); + + const traceIds = new Set(capturedRuns.map(e => e.run.trace_id).filter(Boolean)); + assert.strictEqual( + traceIds.size, + 1, + `All runs should share the same trace_id, got ${traceIds.size} distinct trace_ids`, + ); + }); + }); + + describe('StateGraph with LLM node triggers both chain and LLM hooks', () => { + it('should trace both chain runs (graph) and LLM runs (FakeLLM inside node)', async () => { + const llm = new FakeLLM({ response: 'llm answer' }); + + const graph = new StateGraph(GraphState) + .addNode('ask_llm', async (state: typeof GraphState.State) => { + const response = await llm.invoke(state.query); + return { result: response }; + }) + .addEdge(START, 'ask_llm') + .addEdge('ask_llm', END) + .compile(); + + await graph.invoke({ query: 'what is LangGraph?', result: '' }, { callbacks: [ tracer ] }); + + await sleep(500); + + // Should have chain runs from the graph itself + const chainEntries = capturedRuns.filter(e => e.run.run_type === 'chain'); + assert(chainEntries.length >= 1, `Should have at least one chain run, got ${chainEntries.length}`); + + // Should have LLM runs from the FakeLLM invocation inside the node + const llmEntries = capturedRuns.filter(e => e.run.run_type === 'llm'); + assert(llmEntries.length >= 2, `Should have at least 2 LLM entries (start + end), got ${llmEntries.length}`); + }); + }); + + describe('StateGraph node error triggers onChainError', () => { + it('should trigger error hook and include error status in captured runs', async () => { + const graph = new StateGraph(GraphState) + .addNode('fail_node', () => { + throw new Error('Node execution failed'); + }) + .addEdge(START, 'fail_node') + .addEdge('fail_node', END) + .compile(); + + try { + await graph.invoke({ query: 'trigger error', result: '' }, { callbacks: [ tracer ] }); + } catch { + // Expected error + } + + await sleep(500); + + // Should have a start entry + const startEntries = capturedRuns.filter(e => e.status === RunStatus.START); + assert(startEntries.length >= 1, 'Should have at least one start entry before the error'); + + // Should have an error entry + const errorEntries = capturedRuns.filter(e => e.status === RunStatus.ERROR); + assert(errorEntries.length >= 1, `Should have at least one error entry, got ${errorEntries.length}`); + + // Verify the error run has the error field set + assert(errorEntries[0].run, 'Error run should exist'); + assert(errorEntries[0].run.error, 'Error run should have error field set'); + }); + }); + + describe('Direct hook invocation (unit coverage)', () => { + it('should log tool hooks: onToolStart, onToolEnd, onToolError', () => { + const run = createMockRun({ run_type: 'tool', name: 'BashTool' }); + tracer.onToolStart(run); + tracer.onToolEnd(run); + tracer.onToolError({ ...run, error: 'tool failed' } as Run); + + const toolEntries = capturedRuns.filter(e => e.run.run_type === 'tool'); + assert.strictEqual(toolEntries.length, 3); + assert.strictEqual(toolEntries[0].status, RunStatus.START); + assert.strictEqual(toolEntries[1].status, RunStatus.END); + assert.strictEqual(toolEntries[2].status, RunStatus.ERROR); + }); + + it('should log LLM hooks: onLLMStart, onLLMEnd, onLLMError', () => { + const run = createMockRun({ run_type: 'llm', name: 'claude-3' }); + tracer.onLLMStart(run); + tracer.onLLMEnd(run); + tracer.onLLMError({ ...run, error: 'llm error' } as Run); + + const llmEntries = capturedRuns.filter(e => e.run.run_type === 'llm'); + assert.strictEqual(llmEntries.length, 3); + assert.strictEqual(llmEntries[0].status, RunStatus.START); + assert.strictEqual(llmEntries[1].status, RunStatus.END); + assert.strictEqual(llmEntries[2].status, RunStatus.ERROR); + }); + + it('should log retriever hooks: onRetrieverStart, onRetrieverEnd, onRetrieverError', () => { + const run = createMockRun({ run_type: 'retriever', name: 'VectorRetriever' }); + tracer.onRetrieverStart(run); + tracer.onRetrieverEnd(run); + tracer.onRetrieverError({ ...run, error: 'retriever error' } as Run); + + const retrieverEntries = capturedRuns.filter(e => e.run.run_type === 'retriever'); + assert.strictEqual(retrieverEntries.length, 3); + assert.strictEqual(retrieverEntries[0].status, RunStatus.START); + assert.strictEqual(retrieverEntries[1].status, RunStatus.END); + assert.strictEqual(retrieverEntries[2].status, RunStatus.ERROR); + }); + + it('should log agent hooks: onAgentAction, onAgentEnd', () => { + const run = createMockRun({ run_type: 'chain', name: 'AgentExecutor' }); + tracer.onAgentAction(run); + tracer.onAgentEnd(run); + + assert.strictEqual(capturedRuns[0].status, RunStatus.START); + assert.strictEqual(capturedRuns[1].status, RunStatus.END); + }); + }); + + describe('Run data completeness via graph.invoke', () => { + it('should produce runs with all required fields populated', async () => { + const graph = new StateGraph(GraphState) + .addNode('check', (state: typeof GraphState.State) => { + return { result: `checked: ${state.query}` }; + }) + .addEdge(START, 'check') + .addEdge('check', END) + .compile(); + + await graph.invoke({ query: 'check fields', result: '' }, { callbacks: [ tracer ] }); + + await sleep(500); + + assert(capturedRuns.length > 0, 'Should have captured runs'); + + for (const entry of capturedRuns) { + assert(typeof entry.run.id === 'string' && entry.run.id.length > 0, 'Run must have non-empty id'); + assert( + typeof entry.run.trace_id === 'string' && entry.run.trace_id.length > 0, + 'Run must have non-empty trace_id', + ); + assert(typeof entry.run.run_type === 'string', 'Run must have run_type'); + assert(typeof entry.run.name === 'string', 'Run must have name'); + } + }); + + it('should produce end runs with outputs present', async () => { + const graph = new StateGraph(GraphState) + .addNode('output_node', (state: typeof GraphState.State) => { + return { result: `output for ${state.query}` }; + }) + .addEdge(START, 'output_node') + .addEdge('output_node', END) + .compile(); + + await graph.invoke({ query: 'output test', result: '' }, { callbacks: [ tracer ] }); + + await sleep(500); + + const endEntries = capturedRuns.filter(e => e.status === RunStatus.END); + assert(endEntries.length >= 1, 'Should have end entries'); + + for (const entry of endEntries) { + if (entry.run.outputs) { + assert(typeof entry.run.outputs === 'object', 'End run outputs should be an object'); + } + } + }); + }); +}); diff --git a/core/agent-tracing/test/TestUtils.ts b/core/agent-tracing/test/TestUtils.ts new file mode 100644 index 00000000..7988ef4d --- /dev/null +++ b/core/agent-tracing/test/TestUtils.ts @@ -0,0 +1,64 @@ +import type { Logger } from '@eggjs/tegg-types'; +import type { Run } from '@langchain/core/tracers/base'; + +import { TracingService } from '../src/TracingService'; + +export interface CapturedEntry { + run: Run; + status: string; + name: string; + agentName: string; +} + +export function createMockRun(overrides?: Partial): Run { + return { + id: 'run-001', + name: 'TestRun', + run_type: 'chain', + inputs: {}, + outputs: {}, + start_time: Date.now(), + end_time: Date.now() + 100, + execution_order: 1, + child_execution_order: 1, + child_runs: [], + events: [], + trace_id: 'trace-001', + parent_run_id: undefined, + tags: [], + extra: {}, + error: undefined, + ...overrides, + } as Run; +} + +export function createMockLogger(logs?: string[]): Logger { + return { + info: (msg: string) => { + logs?.push(msg); + }, + warn: (msg: string) => { + logs?.push(msg); + }, + error: (msg: string) => { + logs?.push(msg); + }, + } as unknown as Logger; +} + +/** + * Create a mock TracingService that captures Run objects directly. + * Use capturedRuns to assert on traced runs without parsing log strings. + */ +export function createCapturingTracingService(): { + tracingService: TracingService; + capturedRuns: CapturedEntry[]; +} { + const capturedRuns: CapturedEntry[] = []; + const tracingService = { + logTrace: (run: Run, status: string, name: string, agentName: string) => { + capturedRuns.push({ run, status, name, agentName }); + }, + } as unknown as TracingService; + return { tracingService, capturedRuns }; +} diff --git a/core/agent-tracing/test/TracingService.test.ts b/core/agent-tracing/test/TracingService.test.ts new file mode 100644 index 00000000..40d1003b --- /dev/null +++ b/core/agent-tracing/test/TracingService.test.ts @@ -0,0 +1,341 @@ +import assert from 'node:assert/strict'; + +import { TracingService } from '../src/TracingService'; +import { RunStatus } from '../src/types'; +import { createMockRun } from './TestUtils'; + +// ---------- Helpers ---------- + +function makeTracingService({ + withOss = true, + withLogService = true, +}: { + withOss?: boolean; + withLogService?: boolean; +} = {}) { + const infoLogs: string[] = []; + const warnLogs: string[] = []; + const ossPuts: Array<{ key: string; content: string }> = []; + const logServiceSends: string[] = []; + + const service = new TracingService(); + + (service as any).logger = { + info: (msg: string) => infoLogs.push(msg), + warn: (...args: unknown[]) => warnLogs.push(args.join(' ')), + error: (msg: string) => warnLogs.push(msg), + }; + + (service as any).backgroundTaskHelper = { + run: async (fn: () => Promise) => fn(), + }; + + if (withOss) { + (service as any).ossClient = { + put: async (key: string, content: Buffer) => { + ossPuts.push({ key, content: content.toString() }); + }, + }; + } + + if (withLogService) { + (service as any).logServiceClient = { + send: async (log: string) => { + logServiceSends.push(log); + }, + }; + } + + return { service, infoLogs, warnLogs, ossPuts, logServiceSends }; +} + +// ---------- Tests ---------- + +describe('test/TracingService.test.ts', () => { + let originalFaasEnv: string | undefined; + let originalServerEnv: string | undefined; + + beforeEach(() => { + originalFaasEnv = process.env.FAAS_ENV; + originalServerEnv = process.env.SERVER_ENV; + }); + + afterEach(() => { + if (originalFaasEnv === undefined) { + delete process.env.FAAS_ENV; + } else { + process.env.FAAS_ENV = originalFaasEnv; + } + if (originalServerEnv === undefined) { + delete process.env.SERVER_ENV; + } else { + process.env.SERVER_ENV = originalServerEnv; + } + }); + + describe('getEnv()', () => { + it('should return FAAS_ENV when set', () => { + process.env.FAAS_ENV = 'prod'; + delete process.env.SERVER_ENV; + const { service } = makeTracingService(); + assert.strictEqual(service.getEnv(), 'prod'); + }); + + it('should fall back to SERVER_ENV when FAAS_ENV is not set', () => { + delete process.env.FAAS_ENV; + process.env.SERVER_ENV = 'gray'; + const { service } = makeTracingService(); + assert.strictEqual(service.getEnv(), 'gray'); + }); + + it('should normalize prepub to pre', () => { + delete process.env.FAAS_ENV; + process.env.SERVER_ENV = 'prepub'; + const { service } = makeTracingService(); + assert.strictEqual(service.getEnv(), 'pre'); + }); + + it('should default to local when neither env var is set', () => { + delete process.env.FAAS_ENV; + delete process.env.SERVER_ENV; + const { service } = makeTracingService(); + assert.strictEqual(service.getEnv(), 'local'); + }); + }); + + describe('isOnlineEnv()', () => { + it('should return true for prod', () => { + process.env.FAAS_ENV = 'prod'; + const { service } = makeTracingService(); + assert.strictEqual(service.isOnlineEnv(), true); + }); + + it('should return true for pre', () => { + process.env.FAAS_ENV = 'pre'; + const { service } = makeTracingService(); + assert.strictEqual(service.isOnlineEnv(), true); + }); + + it('should return true for gray', () => { + process.env.FAAS_ENV = 'gray'; + const { service } = makeTracingService(); + assert.strictEqual(service.isOnlineEnv(), true); + }); + + it('should return false for local', () => { + delete process.env.FAAS_ENV; + delete process.env.SERVER_ENV; + const { service } = makeTracingService(); + assert.strictEqual(service.isOnlineEnv(), false); + }); + + it('should return false for dev', () => { + process.env.FAAS_ENV = 'dev'; + const { service } = makeTracingService(); + assert.strictEqual(service.isOnlineEnv(), false); + }); + }); + + describe('getLogInfoPrefix()', () => { + it('should format prefix for root run with FAAS_ENV set', () => { + process.env.FAAS_ENV = 'prod'; + const { service } = makeTracingService(); + const run = createMockRun({ trace_id: 'trace-xyz', id: 'run-123', parent_run_id: undefined }); + const prefix = service.getLogInfoPrefix(run, RunStatus.START, 'MyAgent'); + assert(prefix.includes('[agent_run][MyAgent]')); + assert(prefix.includes('traceId=trace-xyz')); + assert(prefix.includes('threadId=unknown')); + assert(prefix.includes('type=root_run')); + assert(prefix.includes('status=start')); + assert(prefix.includes('run_id=run-123')); + assert(prefix.includes('parent_run_id=')); + }); + + it('should include threadId from run.extra.metadata when available', () => { + process.env.FAAS_ENV = 'dev'; + const { service } = makeTracingService(); + const run = createMockRun({ extra: { metadata: { thread_id: 'thread-abc' } } }); + const prefix = service.getLogInfoPrefix(run, RunStatus.START, 'MyAgent'); + assert(prefix.includes('threadId=thread-abc')); + }); + + it('should mark child run when parent_run_id is set', () => { + process.env.FAAS_ENV = 'dev'; + const { service } = makeTracingService(); + const run = createMockRun({ parent_run_id: 'parent-001' }); + const prefix = service.getLogInfoPrefix(run, RunStatus.END, 'MyAgent'); + assert(prefix.includes('type=child_run')); + assert(prefix.includes('parent_run_id=parent-001')); + }); + + it('should include env segment when SERVER_ENV is set and FAAS_ENV is not', () => { + delete process.env.FAAS_ENV; + process.env.SERVER_ENV = 'pre'; + const { service } = makeTracingService(); + const run = createMockRun(); + const prefix = service.getLogInfoPrefix(run, RunStatus.END, 'MyAgent'); + assert(prefix.includes('env=pre')); + }); + }); + + describe('uploadToOss()', () => { + it('should upload content to OSS client', async () => { + process.env.FAAS_ENV = 'dev'; + const { service, ossPuts } = makeTracingService({ withOss: true }); + await service.uploadToOss('my/key', 'hello content'); + assert.strictEqual(ossPuts.length, 1); + assert.strictEqual(ossPuts[0].key, 'my/key'); + assert.strictEqual(ossPuts[0].content, 'hello content'); + }); + + it('should warn and skip when no OSS client is configured', async () => { + const { service, warnLogs } = makeTracingService({ withOss: false }); + await service.uploadToOss('my/key', 'content'); + assert(warnLogs.some(log => log.includes('OSS client not configured'))); + }); + }); + + describe('syncLocalToLogService()', () => { + it('should send log to log service with agentName prefix', async () => { + const { service, logServiceSends } = makeTracingService({ withLogService: true }); + await service.syncLocalToLogService('trace log line', 'MyAgent'); + assert.strictEqual(logServiceSends.length, 1); + assert(logServiceSends[0].includes('[MyAgent]')); + assert(logServiceSends[0].includes('trace log line')); + }); + + it('should skip silently when no log service client configured', async () => { + const { service, logServiceSends } = makeTracingService({ withLogService: false }); + await service.syncLocalToLogService('trace log line', 'MyAgent'); + assert.strictEqual(logServiceSends.length, 0); + }); + + it('should warn when agentName is empty', async () => { + const { service, warnLogs } = makeTracingService({ withLogService: true }); + await service.syncLocalToLogService('log', ''); + assert(warnLogs.some(log => log.includes('agentName is empty'))); + }); + + it('should handle log service errors gracefully', async () => { + const { service, warnLogs } = makeTracingService({ withLogService: false }); + (service as any).logServiceClient = { + send: async () => { + throw new Error('network error'); + }, + }; + await service.syncLocalToLogService('log', 'MyAgent'); + assert(warnLogs.some(log => log.includes('syncLocalToLogService error'))); + }); + }); + + describe('logTrace()', () => { + it('should log trace via logger.info when FAAS_ENV is set', () => { + process.env.FAAS_ENV = 'dev'; + const { service, infoLogs } = makeTracingService(); + const run = createMockRun({ outputs: undefined }); + service.logTrace(run, RunStatus.END, 'LangGraphTracer', 'MyAgent'); + assert(infoLogs.some(log => log.includes('[agent_run]'))); + }); + + it('should skip runs tagged with langsmith:hidden', () => { + process.env.FAAS_ENV = 'dev'; + const { service, infoLogs } = makeTracingService(); + const run = createMockRun({ tags: [ 'langsmith:hidden' ] }); + service.logTrace(run, RunStatus.END, 'LangGraphTracer', 'MyAgent'); + assert.strictEqual(infoLogs.length, 0); + }); + + it('should upload outputs field to OSS and replace with IResource', async () => { + process.env.FAAS_ENV = 'dev'; + const { service, ossPuts, infoLogs } = makeTracingService({ withOss: true }); + const run = createMockRun({ outputs: { result: 'data', llmOutput: { promptTokens: 10 } } }); + service.logTrace(run, RunStatus.END, 'LangGraphTracer', 'MyAgent'); + // backgroundTaskHelper runs synchronously in mock, so OSS put should be done + assert(ossPuts.length >= 1, 'Should have uploaded to OSS'); + // The logged run should have outputs replaced with IResource + const logLine = infoLogs.find(log => log.includes('[agent_run]')); + assert(logLine, 'Should have a log line'); + const runJson = logLine!.match(/,run=({.*})$/)?.[1]; + assert(runJson, 'Should have run JSON in log'); + const parsed = JSON.parse(runJson); + assert(parsed.outputs?.key, 'outputs should be replaced with IResource'); + assert.strictEqual(parsed.cost?.promptTokens, 10, 'cost should be extracted from llmOutput'); + }); + + it('should sync to log service when env is local', async () => { + delete process.env.FAAS_ENV; + delete process.env.SERVER_ENV; + const { service, logServiceSends } = makeTracingService({ withLogService: true }); + const run = createMockRun({ outputs: undefined }); + service.logTrace(run, RunStatus.END, 'LangGraphTracer', 'MyAgent'); + assert(logServiceSends.length >= 1, 'Should have synced to log service in local env'); + }); + + it('should include child run ids in logged json', () => { + process.env.FAAS_ENV = 'dev'; + const { service, infoLogs } = makeTracingService(); + const childRun = createMockRun({ id: 'child-001' }); + const run = createMockRun({ child_runs: [ childRun ], outputs: undefined }); + service.logTrace(run, RunStatus.END, 'LangGraphTracer', 'MyAgent'); + const logLine = infoLogs.find(log => log.includes('[agent_run]')); + const runJson = logLine?.match(/,run=({.*})$/)?.[1]; + const parsed = JSON.parse(runJson!); + assert.deepStrictEqual(parsed.child_run_ids, [ 'child-001' ]); + }); + + it('should warn when OSS upload fails inside backgroundTask', async () => { + process.env.FAAS_ENV = 'dev'; + const { service, warnLogs } = makeTracingService({ withOss: false }); + + // Make the OSS client throw on put + (service as any).ossClient = { + put: async () => { + throw new Error('oss upload failed'); + }, + }; + + // Track background tasks so we can await them + const pendingTasks: Array> = []; + (service as any).backgroundTaskHelper = { + run: (fn: () => Promise) => { + pendingTasks.push(fn()); + }, + }; + + const run = createMockRun({ outputs: { result: 'data' } }); + service.logTrace(run, RunStatus.END, 'LangGraphTracer', 'MyAgent'); + + // Wait for all background tasks (the catch block inside fn() calls logger.warn) + await Promise.allSettled(pendingTasks); + + assert( + warnLogs.some(log => log.includes('Failed to upload run data to OSS')), + 'Should warn about OSS upload failure', + ); + }); + + it('should catch and warn when logTrace itself throws', () => { + process.env.FAAS_ENV = 'dev'; + const { service, warnLogs } = makeTracingService(); + + // Make backgroundTaskHelper.run throw synchronously to trigger the outer catch + (service as any).backgroundTaskHelper = { + run: () => { + throw new Error('backgroundTask error'); + }, + }; + + const run = createMockRun({ outputs: { result: 'data' } }); + + // Should NOT throw — the outer catch block in logTrace swallows the error + assert.doesNotThrow(() => { + service.logTrace(run, RunStatus.END, 'LangGraphTracer', 'MyAgent'); + }); + + assert( + warnLogs.some(log => log.includes('logTrace error')), + 'Should warn about logTrace error', + ); + }); + }); +}); diff --git a/core/agent-tracing/tsconfig.json b/core/agent-tracing/tsconfig.json new file mode 100644 index 00000000..64b22405 --- /dev/null +++ b/core/agent-tracing/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": "./" + }, + "exclude": [ + "dist", + "node_modules", + "test" + ] +} diff --git a/core/agent-tracing/tsconfig.pub.json b/core/agent-tracing/tsconfig.pub.json new file mode 100644 index 00000000..64b22405 --- /dev/null +++ b/core/agent-tracing/tsconfig.pub.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": "./" + }, + "exclude": [ + "dist", + "node_modules", + "test" + ] +}