From e7528aebd400c59885765f8c14ef0aa983bc7b39 Mon Sep 17 00:00:00 2001 From: Max Ind Date: Thu, 25 Sep 2025 09:24:43 -0700 Subject: [PATCH] feat(otel): adjust telemetry to follow OTLP 1.37 GenAI semconv Changes include: - Implementing missing attributes. e.g. 'gen_ai.agent.name' - Specifying reasons for not filling out some conditionally required attributes. e.g. 'gen_ai.data_source.id' - Specifying reasons for not including certain attributes which are specified in current semconv. e.g. inference attributes on agent spans PiperOrigin-RevId: 811379706 --- pyproject.toml | 4 +- src/google/adk/agents/base_agent.py | 14 +- src/google/adk/telemetry/tracing.py | 130 ++++++++++++++----- tests/unittests/telemetry/test_functional.py | 2 +- tests/unittests/telemetry/test_spans.py | 42 ++++-- 5 files changed, 140 insertions(+), 52 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 224f201c..f28cd4a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,13 +41,13 @@ dependencies = [ "google-genai>=1.21.1, <2.0.0", # Google GenAI SDK "graphviz>=0.20.2, <1.0.0", # Graphviz for graph rendering "mcp>=1.8.0, <2.0.0;python_version>='3.10'", # For MCP Toolset - "opentelemetry-api>=1.31.0, <=1.37.0", # OpenTelemetry - limit upper version for sdk and api to not risk breaking changes from unstable _logs package. + "opentelemetry-api>=1.37.0, <=1.37.0", # OpenTelemetry - limit upper version for sdk and api to not risk breaking changes from unstable _logs package. "opentelemetry-exporter-gcp-logging>=1.9.0a0, <2.0.0", "opentelemetry-exporter-gcp-monitoring>=1.9.0a0, <2.0.0", "opentelemetry-exporter-gcp-trace>=1.9.0, <2.0.0", "opentelemetry-exporter-otlp-proto-http>=1.36.0", "opentelemetry-resourcedetector-gcp>=1.9.0a0, <2.0.0", - "opentelemetry-sdk>=1.31.0, <=1.37.0", + "opentelemetry-sdk>=1.37.0, <=1.37.0", "pydantic>=2.0, <3.0.0", # For data validation/models "python-dateutil>=2.9.0.post0, <3.0.0", # For Vertext AI Session Service "python-dotenv>=1.0.0, <2.0.0", # To manage environment variables diff --git a/src/google/adk/agents/base_agent.py b/src/google/adk/agents/base_agent.py index cc2257ff..fe3502b9 100644 --- a/src/google/adk/agents/base_agent.py +++ b/src/google/adk/agents/base_agent.py @@ -30,7 +30,6 @@ from typing import TypeVar from typing import Union from google.genai import types -from opentelemetry import trace from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field @@ -39,17 +38,16 @@ from typing_extensions import override from typing_extensions import TypeAlias from ..events.event import Event +from ..telemetry import tracing +from ..telemetry.tracing import tracer from ..utils.context_utils import Aclosing from ..utils.feature_decorator import experimental from .base_agent_config import BaseAgentConfig from .callback_context import CallbackContext -from .common_configs import AgentRefConfig if TYPE_CHECKING: from .invocation_context import InvocationContext -tracer = trace.get_tracer('gcp.vertex.agent') - _SingleAgentCallback: TypeAlias = Callable[ [CallbackContext], Union[Awaitable[Optional[types.Content]], Optional[types.Content]], @@ -226,9 +224,9 @@ class BaseAgent(BaseModel): """ async def _run_with_trace() -> AsyncGenerator[Event, None]: - with tracer.start_as_current_span(f'agent_run [{self.name}]'): + with tracer.start_as_current_span(f'invoke_agent {self.name}') as span: ctx = self._create_invocation_context(parent_context) - + tracing.trace_agent_invocation(span, self, ctx) if event := await self.__handle_before_agent_callback(ctx): yield event if ctx.end_invocation: @@ -264,9 +262,9 @@ class BaseAgent(BaseModel): """ async def _run_with_trace() -> AsyncGenerator[Event, None]: - with tracer.start_as_current_span(f'agent_run [{self.name}]'): + with tracer.start_as_current_span(f'invoke_agent {self.name}') as span: ctx = self._create_invocation_context(parent_context) - + tracing.trace_agent_invocation(span, self, ctx) if event := await self.__handle_before_agent_callback(ctx): yield event if ctx.end_invocation: diff --git a/src/google/adk/telemetry/tracing.py b/src/google/adk/telemetry/tracing.py index 74fdbfb4..12fbca1d 100644 --- a/src/google/adk/telemetry/tracing.py +++ b/src/google/adk/telemetry/tracing.py @@ -25,17 +25,38 @@ from __future__ import annotations import json from typing import Any +from typing import TYPE_CHECKING from google.genai import types from opentelemetry import trace -from ..agents.invocation_context import InvocationContext +from .. import version from ..events.event import Event -from ..models.llm_request import LlmRequest -from ..models.llm_response import LlmResponse -from ..tools.base_tool import BaseTool -tracer = trace.get_tracer('gcp.vertex.agent') +# TODO: Replace with constant from opentelemetry.semconv when it reaches version 1.37 in g3. +GEN_AI_AGENT_DESCRIPTION = 'gen_ai.agent.description' +GEN_AI_AGENT_NAME = 'gen_ai.agent.name' +GEN_AI_CONVERSATION_ID = 'gen_ai.conversation.id' +GEN_AI_OPERATION_NAME = 'gen_ai.operation.name' +GEN_AI_TOOL_CALL_ID = 'gen_ai.tool.call.id' +GEN_AI_TOOL_DESCRIPTION = 'gen_ai.tool.description' +GEN_AI_TOOL_NAME = 'gen_ai.tool.name' +GEN_AI_TOOL_TYPE = 'gen_ai.tool.type' + +# Needed to avoid circular imports +if TYPE_CHECKING: + from ..agents.base_agent import BaseAgent + from ..agents.invocation_context import InvocationContext + from ..models.llm_request import LlmRequest + from ..models.llm_response import LlmResponse + from ..tools.base_tool import BaseTool + +tracer = trace.get_tracer( + instrumenting_module_name='gcp.vertex.agent', + instrumenting_library_version=version.__version__, + # TODO: Replace with constant from opentelemetry.semconv when it reaches version 1.37 in g3. + schema_url='https://opentelemetry.io/schemas/1.37.0', +) def _safe_json_serialize(obj) -> str: @@ -57,6 +78,39 @@ def _safe_json_serialize(obj) -> str: return '' +def trace_agent_invocation( + span: trace.Span, agent: BaseAgent, ctx: InvocationContext +) -> None: + """Sets span attributes immedietely available on agent invocation according to OTEL semconv version 1.37. + + Args: + span: Span on which attributes are set. + agent: Agent from which attributes are gathered. + ctx: InvocationContext from which attrbiutes are gathered. + + Inference related fields are not set, due to their planned removal from invoke_agent span: + https://github.com/open-telemetry/semantic-conventions/issues/2632 + + `gen_ai.agent.id` is not set because currently it's unclear what attributes this field should have, specifically: + - In which scope should it be unique (globally, given project, given agentic flow, given deployment). + - Should it be unchanging between deployments, and how this should this be achieved. + + `gen_ai.data_source.id` is not set because it's not available. + Closest type which could contain this information is types.GroundingMetadata, which does not have an ID. + + `server.*` attributes are not set pending confirmation from aabmass. + """ + + # Required + span.set_attribute(GEN_AI_OPERATION_NAME, 'invoke_agent') + + # Conditionally Required + span.set_attribute(GEN_AI_AGENT_DESCRIPTION, agent.description) + + span.set_attribute(GEN_AI_AGENT_NAME, agent.name) + span.set_attribute(GEN_AI_CONVERSATION_ID, ctx.session.id) + + def trace_tool_call( tool: BaseTool, args: dict[str, Any], @@ -70,40 +124,49 @@ def trace_tool_call( function_response_event: The event with the function response details. """ span = trace.get_current_span() - span.set_attribute('gen_ai.system', 'gcp.vertex.agent') - span.set_attribute('gen_ai.operation.name', 'execute_tool') - span.set_attribute('gen_ai.tool.name', tool.name) - span.set_attribute('gen_ai.tool.description', tool.description) - tool_call_id = '' - tool_response = '' - if function_response_event.content.parts: - function_response = function_response_event.content.parts[ - 0 - ].function_response - if function_response is not None: - tool_call_id = function_response.id - tool_response = function_response.response - span.set_attribute('gen_ai.tool.call.id', tool_call_id) + span.set_attribute(GEN_AI_OPERATION_NAME, 'execute_tool') + + span.set_attribute(GEN_AI_TOOL_DESCRIPTION, tool.description) + span.set_attribute(GEN_AI_TOOL_NAME, tool.name) + + # e.g. FunctionTool + span.set_attribute(GEN_AI_TOOL_TYPE, tool.__class__.__name__) + + # Setting empty llm request and response (as UI expect these) while not + # applicable for tool_response. + span.set_attribute('gcp.vertex.agent.llm_request', '{}') + span.set_attribute('gcp.vertex.agent.llm_response', '{}') - if not isinstance(tool_response, dict): - tool_response = {'result': tool_response} span.set_attribute( 'gcp.vertex.agent.tool_call_args', _safe_json_serialize(args), ) + + # Tracing tool response + tool_call_id = '' + tool_response = '' + if ( + function_response_event.content is not None + and function_response_event.content.parts + ): + response_parts = function_response_event.content.parts + function_response = response_parts[0].function_response + if function_response is not None: + if function_response.id is not None: + tool_call_id = function_response.id + if function_response.response is not None: + tool_response = function_response.response + + span.set_attribute(GEN_AI_TOOL_CALL_ID, tool_call_id) + + if not isinstance(tool_response, dict): + tool_response = {'result': tool_response} span.set_attribute('gcp.vertex.agent.event_id', function_response_event.id) span.set_attribute( 'gcp.vertex.agent.tool_response', _safe_json_serialize(tool_response), ) - # Setting empty llm request and response (as UI expect these) while not - # applicable for tool_response. - span.set_attribute('gcp.vertex.agent.llm_request', '{}') - span.set_attribute( - 'gcp.vertex.agent.llm_response', - '{}', - ) def trace_merged_tool_calls( @@ -121,12 +184,13 @@ def trace_merged_tool_calls( """ span = trace.get_current_span() - span.set_attribute('gen_ai.system', 'gcp.vertex.agent') - span.set_attribute('gen_ai.operation.name', 'execute_tool') - span.set_attribute('gen_ai.tool.name', '(merged tools)') - span.set_attribute('gen_ai.tool.description', '(merged tools)') - span.set_attribute('gen_ai.tool.call.id', response_event_id) + span.set_attribute(GEN_AI_OPERATION_NAME, 'execute_tool') + span.set_attribute(GEN_AI_TOOL_NAME, '(merged tools)') + span.set_attribute(GEN_AI_TOOL_DESCRIPTION, '(merged tools)') + span.set_attribute(GEN_AI_TOOL_CALL_ID, response_event_id) + + # TODO(b/441461932): See if these are still necessary span.set_attribute('gcp.vertex.agent.tool_call_args', 'N/A') span.set_attribute('gcp.vertex.agent.event_id', response_event_id) try: diff --git a/tests/unittests/telemetry/test_functional.py b/tests/unittests/telemetry/test_functional.py index 08cefa44..100ec40a 100644 --- a/tests/unittests/telemetry/test_functional.py +++ b/tests/unittests/telemetry/test_functional.py @@ -115,7 +115,7 @@ async def test_tracer_start_as_current_span( expected_start_as_current_span_calls = [ mock.call('invocation'), mock.call('execute_tool some_tool'), - mock.call('agent_run [some_root_agent]'), + mock.call('invoke_agent some_root_agent'), mock.call('call_llm'), mock.call('call_llm'), ] diff --git a/tests/unittests/telemetry/test_spans.py b/tests/unittests/telemetry/test_spans.py index 812539f1..cfd2b745 100644 --- a/tests/unittests/telemetry/test_spans.py +++ b/tests/unittests/telemetry/test_spans.py @@ -23,6 +23,7 @@ from google.adk.agents.llm_agent import LlmAgent from google.adk.models.llm_request import LlmRequest from google.adk.models.llm_response import LlmResponse from google.adk.sessions.in_memory_session_service import InMemorySessionService +from google.adk.telemetry.tracing import trace_agent_invocation from google.adk.telemetry.tracing import trace_call_llm from google.adk.telemetry.tracing import trace_merged_tool_calls from google.adk.telemetry.tracing import trace_tool_call @@ -80,6 +81,30 @@ async def _create_invocation_context( return invocation_context +@pytest.mark.asyncio +async def test_trace_agent_invocation(mock_span_fixture): + """Test trace_agent_invocation sets span attributes correctly.""" + agent = LlmAgent(name='test_llm_agent', model='gemini-pro') + agent.description = 'Test agent description' + invocation_context = await _create_invocation_context(agent) + + trace_agent_invocation(mock_span_fixture, agent, invocation_context) + + expected_calls = [ + mock.call('gen_ai.operation.name', 'invoke_agent'), + mock.call('gen_ai.agent.description', agent.description), + mock.call('gen_ai.agent.name', agent.name), + mock.call( + 'gen_ai.conversation.id', + invocation_context.session.id, + ), + ] + mock_span_fixture.set_attribute.assert_has_calls( + expected_calls, any_order=True + ) + assert mock_span_fixture.set_attribute.call_count == len(expected_calls) + + @pytest.mark.asyncio async def test_trace_call_llm(monkeypatch, mock_span_fixture): """Test trace_call_llm sets all telemetry attributes correctly with normal content.""" @@ -90,6 +115,7 @@ async def test_trace_call_llm(monkeypatch, mock_span_fixture): agent = LlmAgent(name='test_agent') invocation_context = await _create_invocation_context(agent) llm_request = LlmRequest( + model='gemini-pro', contents=[ types.Content( role='user', @@ -97,7 +123,6 @@ async def test_trace_call_llm(monkeypatch, mock_span_fixture): ), ], config=types.GenerateContentConfig( - system_instruction='You are a helpful assistant.', top_p=0.95, max_output_tokens=1024, ), @@ -117,6 +142,7 @@ async def test_trace_call_llm(monkeypatch, mock_span_fixture): mock.call('gen_ai.system', 'gcp.vertex.agent'), mock.call('gen_ai.request.top_p', 0.95), mock.call('gen_ai.request.max_tokens', 1024), + mock.call('gcp.vertex.agent.llm_response', mock.ANY), mock.call('gen_ai.usage.input_tokens', 50), mock.call('gen_ai.usage.output_tokens', 50), mock.call('gen_ai.response.finish_reasons', ['stop']), @@ -139,6 +165,7 @@ async def test_trace_call_llm_with_binary_content( agent = LlmAgent(name='test_agent') invocation_context = await _create_invocation_context(agent) llm_request = LlmRequest( + model='gemini-pro', contents=[ types.Content( role='user', @@ -166,7 +193,7 @@ async def test_trace_call_llm_with_binary_content( ], ), ], - config=types.GenerateContentConfig(system_instruction=''), + config=types.GenerateContentConfig(), ) llm_response = LlmResponse(turn_complete=True) trace_call_llm(invocation_context, 'test_event_id', llm_request, llm_response) @@ -228,12 +255,11 @@ def test_trace_tool_call_with_scalar_response( ) # Assert - assert mock_span_fixture.set_attribute.call_count == 10 expected_calls = [ - mock.call('gen_ai.system', 'gcp.vertex.agent'), mock.call('gen_ai.operation.name', 'execute_tool'), mock.call('gen_ai.tool.name', mock_tool_fixture.name), mock.call('gen_ai.tool.description', mock_tool_fixture.description), + mock.call('gen_ai.tool.type', 'BaseTool'), mock.call('gen_ai.tool.call.id', test_tool_call_id), mock.call('gcp.vertex.agent.tool_call_args', json.dumps(test_args)), mock.call('gcp.vertex.agent.event_id', test_event_id), @@ -245,6 +271,7 @@ def test_trace_tool_call_with_scalar_response( mock.call('gcp.vertex.agent.llm_response', '{}'), ] + assert mock_span_fixture.set_attribute.call_count == len(expected_calls) mock_span_fixture.set_attribute.assert_has_calls( expected_calls, any_order=True ) @@ -289,10 +316,10 @@ def test_trace_tool_call_with_dict_response( # Assert expected_calls = [ - mock.call('gen_ai.system', 'gcp.vertex.agent'), mock.call('gen_ai.operation.name', 'execute_tool'), mock.call('gen_ai.tool.name', mock_tool_fixture.name), mock.call('gen_ai.tool.description', mock_tool_fixture.description), + mock.call('gen_ai.tool.type', 'BaseTool'), mock.call('gen_ai.tool.call.id', test_tool_call_id), mock.call('gcp.vertex.agent.tool_call_args', json.dumps(test_args)), mock.call('gcp.vertex.agent.event_id', test_event_id), @@ -303,7 +330,7 @@ def test_trace_tool_call_with_dict_response( mock.call('gcp.vertex.agent.llm_response', '{}'), ] - assert mock_span_fixture.set_attribute.call_count == 10 + assert mock_span_fixture.set_attribute.call_count == len(expected_calls) mock_span_fixture.set_attribute.assert_has_calls( expected_calls, any_order=True ) @@ -328,7 +355,6 @@ def test_trace_merged_tool_calls_sets_correct_attributes( ) expected_calls = [ - mock.call('gen_ai.system', 'gcp.vertex.agent'), mock.call('gen_ai.operation.name', 'execute_tool'), mock.call('gen_ai.tool.name', '(merged tools)'), mock.call('gen_ai.tool.description', '(merged tools)'), @@ -340,7 +366,7 @@ def test_trace_merged_tool_calls_sets_correct_attributes( mock.call('gcp.vertex.agent.llm_response', '{}'), ] - assert mock_span_fixture.set_attribute.call_count == 10 + assert mock_span_fixture.set_attribute.call_count == len(expected_calls) mock_span_fixture.set_attribute.assert_has_calls( expected_calls, any_order=True )