mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
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:
committed by
Copybara-Service
parent
cbb6e4945a
commit
e7528aebd4
+2
-2
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user