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
This commit is contained in:
Max Ind
2025-09-25 09:25:15 -07:00
committed by Copybara-Service
parent cbb6e4945a
commit e7528aebd4
5 changed files with 140 additions and 52 deletions
+2 -2
View File
@@ -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
+6 -8
View File
@@ -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:
+97 -33
View File
@@ -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 '<not serializable>'
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 = '<not specified>'
tool_response = '<not specified>'
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 = '<not specified>'
tool_response = '<not specified>'
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:
+1 -1
View File
@@ -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'),
]
+34 -8
View File
@@ -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
)