Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions core/src/agents/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ async function callToolAsync(
),
});
return result;
} catch (e: unknown) {
traceToolCall({tool, args, error: e});
throw e;
} finally {
span.end();
}
Expand Down
29 changes: 24 additions & 5 deletions core/src/telemetry/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,18 @@
*/

import {Content} from '@google/genai';
import {context, Context, trace} from '@opentelemetry/api';
import {context, Context, SpanStatusCode, trace} from '@opentelemetry/api';

import {BaseAgent} from '../agents/base_agent.js';
import {InvocationContext} from '../agents/invocation_context.js';
import {Event} from '../events/event.js';
import {LlmRequest} from '../models/llm_request.js';
import {LlmResponse} from '../models/llm_response.js';
import {BaseTool} from '../tools/base_tool.js';
import {resolveErrorType} from '../utils/error_utils.js';
import {version} from '../version.js';

const ERROR_TYPE = 'error.type';
const GEN_AI_AGENT_DESCRIPTION = 'gen_ai.agent.description';
const GEN_AI_AGENT_NAME = 'gen_ai.agent.name';
const GEN_AI_CONVERSATION_ID = 'gen_ai.conversation.id';
Expand Down Expand Up @@ -93,18 +95,26 @@ export function traceAgentInvocation({
export interface TraceToolCallParams {
tool: BaseTool;
args: Record<string, unknown>;
functionResponseEvent: Event;
/** Absent when the tool threw: there is no response to describe. */
functionResponseEvent?: Event;
/**
* The error the tool threw, if any. A thrown error is the authoritative
* classification for the span.
*/
error?: unknown;
}

/**
* Traces tool call.
*
* @param params The parameters object containing tool, args, and function response event.
* @param params The parameters object containing tool, args, the function
* response event, and the error the tool threw.
*/
export function traceToolCall({
tool,
args,
functionResponseEvent,
error,
}: TraceToolCallParams): void {
const span = trace.getActiveSpan();
if (!span) return;
Expand All @@ -124,11 +134,20 @@ export function traceToolCall({
: '{}',
});

if (error !== undefined) {
const failureType = resolveErrorType(error);
span.recordException(error instanceof Error ? error : String(error));
span.setAttribute(ERROR_TYPE, failureType);
// The type rather than the message, so that no tool content lands in a
// field the content toggle cannot gate.
span.setStatus({code: SpanStatusCode.ERROR, message: failureType});
}

// Tracing tool response
let toolCallId = '<not specified>';
let toolResponse: unknown = '<not specified>';

if (functionResponseEvent.content?.parts) {
if (functionResponseEvent?.content?.parts) {
const responseParts = functionResponseEvent.content.parts;
const functionResponse = responseParts[0]?.functionResponse;
if (functionResponse?.id) {
Expand All @@ -144,7 +163,7 @@ export function traceToolCall({

span.setAttributes({
[GEN_AI_TOOL_CALL_ID]: toolCallId,
'gcp.vertex.agent.event_id': functionResponseEvent.id,
'gcp.vertex.agent.event_id': functionResponseEvent?.id,
'gcp.vertex.agent.tool_response': shouldAddRequestResponseToSpans()
? safeJsonSerialize(toolResponse)
: '{}',
Expand Down
38 changes: 38 additions & 0 deletions core/src/utils/error_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,41 @@ function formatErrorRecursive(err: unknown, seen: Set<unknown>): string {
export function formatError(err: unknown): string {
return formatErrorRecursive(err, new Set<unknown>());
}

/**
* Derives the `error.type` telemetry label for a failure.
*
* Prefers, in order: an `errorType` the error classified itself with; an HTTP
* status the error carries (the `@google/genai` `ApiError` reports one, and the
* SDK collapses every 4xx into a single class and every 5xx into another, so
* the status is the only signal that tells them apart); finally the class name.
*
* The status is duck-typed rather than matched with `instanceof`, because two
* copies of `@google/genai` can share one runtime and an error raised by one is
* not an `instanceof` the class of the other. It is bounded to a plausible HTTP
* range so that an unrelated numeric `status` field is not reported as a status
* code.
*
* @param error The thrown value to classify. JavaScript allows any value to be
* thrown, so this accepts `unknown` rather than `Error`.
* @return The value to report as `error.type`.
*/
export function resolveErrorType(error: unknown): string {
const record = asRecord(error);
const errorType = record?.['errorType'];
if (typeof errorType === 'string') {
return errorType;
}
const status = record?.['status'];
if (
typeof status === 'number' &&
status >= MIN_HTTP_STATUS &&
status <= MAX_HTTP_STATUS
) {
return String(status);
}
// `constructor.name` rather than `name`, mirroring Python's
// `type(error).__name__`: a subclass that never assigns `this.name` still
// reports its own class.
return error instanceof Error ? error.constructor.name : String(error);
}
192 changes: 192 additions & 0 deletions core/test/telemetry/tool_exception_span_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {
BaseTool,
Event,
functionsExportedForTestingOnly,
InvocationContext,
LlmAgent,
PluginManager,
Session,
} from '@google/adk';
import {FunctionCall} from '@google/genai';
import {SpanStatusCode, trace} from '@opentelemetry/api';
import {
InMemorySpanExporter,
ReadableSpan,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import {NodeTracerProvider} from '@opentelemetry/sdk-trace-node';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
} from 'vitest';

const {handleFunctionCallList} = functionsExportedForTestingOnly;

/** An error that classifies itself, the way an MCP tool failure does. */
class ClassifiedError extends Error {
constructor(
message: string,
readonly errorType: string,
) {
super(message);
}
}

/** The error a plain throwing tool raises. */
class InventoryLookupError extends Error {}

/**
* A tool that throws. `FunctionTool` re-wraps a thrown error into a plain
* `Error`, which would flatten the class these tests assert on, so these
* fixtures subclass `BaseTool` directly.
*/
class ThrowingTool extends BaseTool {
constructor(private readonly error: unknown) {
super({name: 'throwingTool', description: 'always throws'});
}

override async runAsync(): Promise<unknown> {
throw this.error;
}
}

/** A tool that succeeds, used to pin the unchanged success path. */
class SucceedingTool extends BaseTool {
constructor() {
super({name: 'succeedingTool', description: 'always succeeds'});
}

override async runAsync(): Promise<unknown> {
return {result: 'tool executed'};
}
}

describe('execute_tool span for a throwing tool', () => {
let exporter: InMemorySpanExporter;
let provider: NodeTracerProvider;
let invocationContext: InvocationContext;

// The tracer in tracing.ts caches its delegate on first use, so the provider
// is registered once for the whole file and only the exporter is reset.
beforeAll(() => {
exporter = new InMemorySpanExporter();
provider = new NodeTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
provider.register();
});

afterAll(async () => {
await provider.shutdown();
trace.disable();
});

beforeEach(() => {
invocationContext = new InvocationContext({
invocationId: 'inv_123',
session: {} as Session,
agent: new LlmAgent({name: 'test_agent', model: 'test_model'}),
pluginManager: new PluginManager(),
});
});

afterEach(() => {
exporter.reset();
});

/** Runs one tool through the production call path and returns its event. */
async function runTool(tool: BaseTool): Promise<Event | null> {
const functionCall: FunctionCall = {
id: 'call_1',
name: tool.name,
args: {},
};
return handleFunctionCallList({
invocationContext,
functionCalls: [functionCall],
toolsDict: {[tool.name]: tool},
beforeToolCallbacks: [],
afterToolCallbacks: [],
});
}

/** Returns the single exported `execute_tool` span. */
function toolSpan(tool: BaseTool): ReadableSpan {
const spans = exporter
.getFinishedSpans()
.filter((span) => span.name === `execute_tool ${tool.name}`);
expect(spans).toHaveLength(1);
return spans[0];
}

it('marks the span as failed and records the exception', async () => {
const tool = new ThrowingTool(new InventoryLookupError('sku not found'));

await runTool(tool);

const span = toolSpan(tool);
expect(span.status.code).toBe(SpanStatusCode.ERROR);
expect(span.status.message).toBe('InventoryLookupError');
expect(span.attributes['error.type']).toBe('InventoryLookupError');
expect(span.attributes['gen_ai.tool.name']).toBe('throwingTool');
const exceptions = span.events.filter((e) => e.name === 'exception');
expect(exceptions).toHaveLength(1);
expect(exceptions[0].attributes?.['exception.message']).toBe(
'sku not found',
);
});

it('reports no response event on the error path', async () => {
const tool = new ThrowingTool(new InventoryLookupError('sku not found'));

await runTool(tool);

const span = toolSpan(tool);
expect('gcp.vertex.agent.event_id' in span.attributes).toBe(false);
expect(span.attributes['gen_ai.tool.call.id']).toBe('<not specified>');
});

it('lets the exception reach the caller unchanged', async () => {
const tool = new ThrowingTool(new InventoryLookupError('sku not found'));

const event = await runTool(tool);

expect(event?.content?.parts?.[0].functionResponse?.response).toEqual({
error: 'sku not found',
});
});

it('prefers the error type the thrown error classified itself with', async () => {
const tool = new ThrowingTool(
new ClassifiedError('upstream refused', 'TOOL_ERROR'),
);

await runTool(tool);

const span = toolSpan(tool);
expect(span.attributes['error.type']).toBe('TOOL_ERROR');
expect(span.status.message).toBe('TOOL_ERROR');
});

it('leaves the span of a succeeding tool clean', async () => {
const tool = new SucceedingTool();

await runTool(tool);

const span = toolSpan(tool);
expect(span.status.code).not.toBe(SpanStatusCode.ERROR);
expect('error.type' in span.attributes).toBe(false);
expect(span.events.filter((e) => e.name === 'exception')).toHaveLength(0);
});
});
Loading
Loading