chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,737 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared infrastructure for the telemetry functional tests.
This module hosts:
* The ``SpanDigest`` / ``LogDigest`` types used to build a deterministic
comparison shape for in-memory spans + log records.
* ``install_telemetry`` which patches an in-memory tracer + log exporter
onto ADK's globals.
* The canonical agent / tool / mock-LLM scenario shared across the
``test_functional.py``, ``test_node_functional.py`` and
``test_web_ui_functional.py`` test suites.
* The ``FunctionalTestCase`` carrier used to parametrize tests against the
hand-written expected shapes in ``functional_test_cases.py`` /
``functional_node_test_cases.py``.
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from collections.abc import Iterator
from contextlib import aclosing
from contextlib import contextmanager
from dataclasses import dataclass
from dataclasses import field
from enum import Enum
import gc
import inspect
import json
import sys
from types import CodeType
from typing import Literal
from typing import NamedTuple
from typing import TYPE_CHECKING
from google.adk.agents.llm_agent import Agent
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.adk.telemetry import _metrics
from google.adk.telemetry import node_tracing
from google.adk.telemetry import tracing
from google.adk.tools.function_tool import FunctionTool
from google.adk.workflow._base_node import START
from google.adk.workflow._workflow import Workflow
from google.genai.types import Content
from google.genai.types import FinishReason
from google.genai.types import Part
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import SimpleLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import HistogramDataPoint
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
from opentelemetry.sdk.metrics.export import NumberDataPoint
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
import pytest
if TYPE_CHECKING:
from google.adk.events.event import Event
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.util.types import AttributeValue
from opentelemetry.sdk._logs import ReadableLogRecord
from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter
from opentelemetry.sdk.metrics.export import MetricsData
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from ..testing_utils import MockModel
from ..testing_utils import TestInMemoryRunner
# ---------------------------------------------------------------------------
# Env var + semconv constants.
# ---------------------------------------------------------------------------
OTEL_OPT_IN = "OTEL_SEMCONV_STABILITY_OPT_IN"
CAPTURE_CONTENT = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
EXPERIMENTAL_OPT_IN = "gen_ai_latest_experimental"
ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN = "ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN"
# Stable semconv event names.
GEN_AI_SYSTEM_MESSAGE_EVENT = "gen_ai.system.message"
GEN_AI_USER_MESSAGE_EVENT = "gen_ai.user.message"
GEN_AI_CHOICE_EVENT = "gen_ai.choice"
# Experimental semconv event name.
GEN_AI_COMPLETION_DETAILS_EVENT = "gen_ai.client.inference.operation.details"
# Difficult to extract, non deterministic attribute keys.
# We check only for their presence, instead of their values.
NON_DETERMINISTIC_ATTRIBUTE_KEYS: frozenset[str] = frozenset({
"gcp.vertex.agent.event_id",
"gen_ai.tool.call.id",
"gcp.vertex.agent.associated_event_ids",
"gen_ai.conversation.id",
"gcp.vertex.agent.invocation_id",
"gcp.vertex.agent.session_id",
})
# Span attribute keys whose values are JSON-serialized strings.
# These are parsed back into Python objects before comparison so that JSON
# property ordering doesn't drive test stability.
JSON_ATTRIBUTE_KEYS: frozenset[str] = frozenset({
"gen_ai.input.messages",
"gen_ai.output.messages",
"gen_ai.system_instructions",
"gen_ai.tool.definitions",
})
# Sentinel used for non deterministic fields that we still want to assert as
# being present.
PRESENT = "PRESENT"
# ---------------------------------------------------------------------------
# Digests.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class LogDigest:
"""A deterministic digest of a ``ReadableLogRecord``.
``attributes`` and ``body`` are normalized via ``_normalize`` so test
expectations can be written using plain Python literals (lists/dicts).
"""
event_name: str
body: object = None
attributes: dict[str, object] = field(default_factory=dict)
@classmethod
def from_log(cls, log: ReadableLogRecord) -> LogDigest:
attrs: dict[str, object] = {}
for k, v in (log.log_record.attributes or {}).items():
if k in NON_DETERMINISTIC_ATTRIBUTE_KEYS:
attrs[k] = PRESENT
else:
attrs[k] = _normalize(v)
return cls(
event_name=log.log_record.event_name or "",
body=_normalize(log.log_record.body),
attributes=attrs,
)
@dataclass(frozen=True)
class SpanDigest:
"""A deterministic digest of a span in the in-memory span tree.
In addition to the span's own name + attributes + child spans, each
digest also carries the ``LogDigest`` records that were emitted while
the span was the active span (matched by ``log_record.span_id``).
"""
name: str
attributes: dict[str, AttributeValue]
children: list[SpanDigest] = field(default_factory=list)
logs: list[LogDigest] = field(default_factory=list)
@classmethod
def from_span(cls, span: ReadableSpan) -> SpanDigest:
"""Builds a single ``SpanDigest`` (no children, no logs) from a span.
Attribute values are normalized so that:
* Non-deterministic keys collapse to the ``PRESENT`` sentinel.
* JSON-serialized attribute values are parsed into Python objects.
* All other values pass through ``_normalize`` (tuples → lists,
enums → ``.value``, ``None`` dict entries dropped).
"""
determinized_attributes: dict[str, AttributeValue] = {}
for attr_key, attr_val in (span.attributes or {}).items():
if attr_key in NON_DETERMINISTIC_ATTRIBUTE_KEYS:
determinized_attributes[attr_key] = PRESENT
elif attr_key in JSON_ATTRIBUTE_KEYS and isinstance(attr_val, str):
determinized_attributes[attr_key] = _normalize(json.loads(attr_val))
else:
determinized_attributes[attr_key] = _normalize(attr_val)
return cls(name=span.name, attributes=determinized_attributes)
@classmethod
def build(
cls,
spans: tuple[ReadableSpan, ...],
logs: tuple[ReadableLogRecord, ...] = (),
) -> SpanDigest:
"""Builds the in-memory span tree, attaching logs by span id.
Used for clear diffs with pytest assertions.
"""
digest_by_id: dict[int, SpanDigest] = {}
for span in spans:
if span.context is None:
continue
digest_by_id[span.context.span_id] = cls.from_span(span)
# Attach each log to its enclosing span (matched by span_id).
for log in logs:
span_id = log.log_record.span_id
if span_id is None or span_id == 0:
continue
digest = digest_by_id.get(span_id)
if digest is None:
continue
digest.logs.append(LogDigest.from_log(log))
root: SpanDigest | None = None
for span in spans:
if span.context is None:
continue
digest = digest_by_id[span.context.span_id]
if span.parent and span.parent.span_id in digest_by_id:
parent_digest = digest_by_id[span.parent.span_id]
parent_digest.children.append(digest)
else:
if root is not None:
raise ValueError("Multiple root spans found.")
root = digest
# Sort for deterministic comparisons.
for digest in digest_by_id.values():
digest.children.sort(key=lambda s: s.name)
digest.logs[:] = sorted_log_digests(digest.logs)
if root is None:
raise ValueError("No root span found in the provided spans.")
return root
def all_logs(self) -> list[LogDigest]:
"""Returns all log digests in the tree, sorted deterministically."""
collected: list[LogDigest] = []
def _walk(node: SpanDigest) -> None:
collected.extend(node.logs)
for child in node.children:
_walk(child)
_walk(self)
return sorted_log_digests(collected)
def sorted_log_digests(logs: list[LogDigest]) -> list[LogDigest]:
"""Returns ``logs`` sorted in a stable, content-derived order."""
return sorted(
logs,
key=lambda log: (
log.event_name,
json.dumps(log.body, sort_keys=True, default=str),
json.dumps(log.attributes, sort_keys=True, default=str),
),
)
class _NonDeterministic:
"""Sentinel for a metric value that is non-deterministic (e.g. wall-clock)."""
__slots__ = ()
def __repr__(self) -> str:
return "NON_DETERMINISTIC"
# Marks a recorded metric value that cannot be pinned (e.g. ``*.duration``
# wall-clock timings); used in place of the actual value on both sides.
NON_DETERMINISTIC = _NonDeterministic()
@dataclass(frozen=True)
class MetricPoint:
"""A single recorded metric data point."""
attributes: dict[str, AttributeValue]
value: object
def __hash__(self) -> int:
return hash(
(json.dumps(self.attributes, sort_keys=True, default=str), self.value)
)
class HistogramSpec(NamedTuple):
"""Locates one ADK metric histogram so a test can redirect it.
``module`` is the module holding the histogram, ``attr`` the global on it to
monkeypatch, and ``metric_name`` the instrument name it is recreated under.
"""
module: object
attr: str
metric_name: str
# Histograms recorded by ADK. Each test redirects these onto an in-memory
# reader so the recorded points can be asserted.
_PATCHED_HISTOGRAMS: tuple[HistogramSpec, ...] = (
HistogramSpec(
module=_metrics,
attr="_agent_invocation_duration",
metric_name="gen_ai.invoke_agent.duration",
),
HistogramSpec(
module=_metrics,
attr="_tool_execution_duration",
metric_name="gen_ai.execute_tool.duration",
),
HistogramSpec(
module=_metrics,
attr="_client_operation_duration",
metric_name="gen_ai.client.operation.duration",
),
HistogramSpec(
module=_metrics,
attr="_client_token_usage",
metric_name="gen_ai.client.token.usage",
),
HistogramSpec(
module=_metrics,
attr="_workflow_invocation_duration",
metric_name="gen_ai.invoke_workflow.duration",
),
HistogramSpec(
module=_metrics,
attr="_invoke_agent_inference_calls",
metric_name="gen_ai.invoke_agent.inference_calls",
),
HistogramSpec(
module=_metrics,
attr="_invoke_agent_tool_calls",
metric_name="gen_ai.invoke_agent.tool_calls",
),
)
def _grouped_metric_points(
metrics_data: MetricsData,
) -> dict[str, frozenset[MetricPoint]]:
"""Groups every recorded point by metric name as an order-free frozenset."""
grouped: dict[str, set[MetricPoint]] = {}
for resource_metric in metrics_data.resource_metrics:
for scope_metric in resource_metric.scope_metrics:
for metric in scope_metric.metrics:
for dp in metric.data.data_points:
# Sum histograms expose ``.sum``; gauge / counter points expose
# ``.value``. isinstance (not hasattr) keeps the typing precise.
if isinstance(dp, HistogramDataPoint):
value = dp.sum
elif isinstance(dp, NumberDataPoint):
value = dp.value
else:
value = NON_DETERMINISTIC
# ``*.duration`` histograms record wall-clock timings, which are
# non-deterministic; replace them so expectations need not pin a
# timing.
if metric.name.endswith(".duration"):
value = NON_DETERMINISTIC
grouped.setdefault(metric.name, set()).add(
MetricPoint(attributes=dict(dp.attributes), value=value)
)
return {name: frozenset(points) for name, points in grouped.items()}
@dataclass(frozen=True)
class TelemetryDigest:
"""The full telemetry surface produced by one scenario run.
Bundles the root span tree (with per-span logs attached) and every recorded
metric point grouped by metric name. Points are held in a frozenset per
group so equality is independent of recording / authoring order. Test cases
hand-write the expected instance; ``build`` produces the actual one.
"""
root_span: SpanDigest
metric_points: dict[str, frozenset[MetricPoint]]
@classmethod
def build(
cls,
spans: tuple[ReadableSpan, ...],
logs: tuple[ReadableLogRecord, ...],
metrics_data: MetricsData,
) -> TelemetryDigest:
"""Builds the actual digest from in-memory spans, logs and metrics."""
return cls(
root_span=SpanDigest.build(spans, logs),
metric_points=_grouped_metric_points(metrics_data),
)
def _normalize(value: object) -> object:
"""Normalizes a value for stable equality.
* Tuples become lists (OTel coerces sequences to tuples on attributes).
* Enums become their ``.value``.
* Dict entries whose value is ``None`` are dropped (these are inserted by
pydantic ``model_dump`` for unset fields and would dominate diffs).
"""
if isinstance(value, Enum):
return value.value
if isinstance(value, tuple):
return [_normalize(v) for v in value]
if isinstance(value, list):
return [_normalize(v) for v in value]
if isinstance(value, dict):
return {k: _normalize(v) for k, v in value.items() if v is not None}
return value
# ---------------------------------------------------------------------------
# Telemetry plumbing.
# ---------------------------------------------------------------------------
def install_telemetry(
monkeypatch: pytest.MonkeyPatch,
span_exporter: InMemorySpanExporter,
log_exporter: InMemoryLogRecordExporter,
metric_reader: InMemoryMetricReader,
) -> None:
"""Installs an in-memory tracer + log exporter + metric reader.
Spans, logs and metric points emitted by ADK during the test are written
into the provided exporters / reader. All three MUST be passed in so each
test makes the choice of sink explicit (e.g. ``InMemoryLogRecordExporter``
vs ``WebUILogExporter``).
"""
tracer_provider = TracerProvider()
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
real_tracer = tracer_provider.get_tracer(__name__)
monkeypatch.setattr(
tracing.tracer,
"start_as_current_span",
real_tracer.start_as_current_span,
)
monkeypatch.setattr(
tracing.tracer,
"start_span",
real_tracer.start_span,
)
monkeypatch.setattr(
node_tracing.tracer,
"start_as_current_span",
real_tracer.start_as_current_span,
)
monkeypatch.setattr(
node_tracing.tracer,
"start_span",
real_tracer.start_span,
)
logger_provider = LoggerProvider()
logger_provider.add_log_record_processor(
SimpleLogRecordProcessor(log_exporter)
)
real_logger = logger_provider.get_logger(__name__)
monkeypatch.setattr(tracing.otel_logger, "emit", real_logger.emit)
meter_provider = MeterProvider(metric_readers=[metric_reader])
meter = meter_provider.get_meter("functional_test_meter")
for spec in _PATCHED_HISTOGRAMS:
monkeypatch.setattr(
spec.module, spec.attr, meter.create_histogram(spec.metric_name)
)
# ---------------------------------------------------------------------------
# Canonical agent / tool / mock-LLM scenario.
# ---------------------------------------------------------------------------
USER_PROMPT = "hello"
AGENT_NAME = "some_root_agent"
AGENT_DESCRIPTION = "A sample root agent."
BASE_INSTRUCTION = "you are helpful"
# ADK auto-appends agent identity info to the system instruction when the
# agent is invoked as the root of an InMemoryRunner directly.
FULL_SYSTEM_INSTRUCTION = (
f"{BASE_INSTRUCTION}\n\n"
f'You are an agent. Your internal name is "{AGENT_NAME}".'
f' The description about you is "{AGENT_DESCRIPTION}".'
)
FINAL_TEXT = "text response"
TOOL_NAME = "some_tool"
TOOL_DESCRIPTION = "A sample tool."
TOOL_ARGS = {"arg1": "val1"}
TOOL_RESULT_PREFIX = "processed "
TOOL_RESULT = f"{TOOL_RESULT_PREFIX}{TOOL_ARGS['arg1']}"
# The node scenario uses a workflow node whose output drives the agent's
# input. The workflow itself wraps the same agent.
WORKFLOW_NAME = "my_workflow"
# The root workflow invokes a nested workflow whose sole node produces the
# input for the agent. The nested workflow exercises the `gen_ai.workflow.nested`
# span attribute + metric dimension (only nested workflows carry it).
NESTED_WORKFLOW_NAME = "my_nested_workflow"
NODE_NAME = "some_node"
NODE_RESULT = "some result"
NODE_USER_ID = "some_user"
NODE_APP_NAME = "some_app"
def _make_llm_response(part: Part) -> LlmResponse:
return LlmResponse(
content=Content(role="model", parts=[part]),
finish_reason=FinishReason.STOP,
)
def build_test_agent(*, failing: bool = False) -> Agent:
"""Builds the canonical 1-tool, 2-LLM-turn agent."""
mock_model = MockModel.create(
responses=[
_make_llm_response(
Part.from_function_call(name=TOOL_NAME, args=TOOL_ARGS)
),
_make_llm_response(Part.from_text(text=FINAL_TEXT)),
]
)
def some_tool(arg1: str) -> str:
"""A sample tool."""
if failing:
raise ValueError("This tool always fails")
return f"{TOOL_RESULT_PREFIX}{arg1}"
return Agent(
name=AGENT_NAME,
description=AGENT_DESCRIPTION,
instruction=BASE_INSTRUCTION,
model=mock_model,
tools=[FunctionTool(some_tool)],
)
def build_test_runner(*, failing: bool = False) -> TestInMemoryRunner:
"""Builds a runner around the canonical agent (no workflow wrapper)."""
return TestInMemoryRunner(node=build_test_agent(failing=failing))
def build_test_workflow(*, failing: bool = False) -> Workflow:
"""Builds the canonical Workflow: a nested workflow feeding the agent."""
test_agent = build_test_agent(failing=failing)
async def some_node(ctx, node_input):
return NODE_RESULT
# Trivial workflow to test o11y of nested workflows
nested_workflow = Workflow(
name=NESTED_WORKFLOW_NAME,
edges=[(START, some_node)],
)
return Workflow(
name=WORKFLOW_NAME,
edges=[(START, nested_workflow, test_agent)],
)
async def run_node_scenario(
*, failing: bool = False, event_sink: list[Event] | None = None
) -> list[Event]:
"""Runs the workflow scenario to completion, draining the event stream.
If ``event_sink`` is provided, collected events are appended to it as they
are drained. This lets callers inspect the events that were emitted before
an exception propagates (e.g. when ``failing=True``).
"""
workflow = build_test_workflow(failing=failing)
runner = InMemoryRunner(app_name=NODE_APP_NAME, node=workflow)
session = await runner.session_service.create_session(
app_name=NODE_APP_NAME, user_id=NODE_USER_ID
)
content = Content(parts=[Part.from_text(text=USER_PROMPT)], role="user")
collected_events: list[Event] = event_sink if event_sink is not None else []
async with aclosing(
runner.run_async(
user_id=NODE_USER_ID,
session_id=session.id,
new_message=content,
)
) as agen:
async for event in agen:
collected_events.append(event)
return collected_events
async def run_agent_scenario(runner: TestInMemoryRunner) -> None:
"""Runs the non-node scenario to completion, draining the event stream."""
async with aclosing(
runner.run_async_with_new_session_agen(
Content(parts=[Part.from_text(text=USER_PROMPT)], role="user")
)
) as agen:
async for _ in agen:
pass
# ---------------------------------------------------------------------------
# Parametrization carrier.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FunctionalTestCase:
"""One row of the (semconv, capture-content, schema-version) matrix."""
test_id: str
semconv_opt_in: str | None
capture_content: str | None
schema_version: Literal[1, 2]
expected: TelemetryDigest
def apply_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Applies the per-case env vars for semconv + content capture.
Always pins ``ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=false`` so the tool
span attributes remain deterministic across all cases.
"""
if self.semconv_opt_in is None:
monkeypatch.delenv(OTEL_OPT_IN, raising=False)
else:
monkeypatch.setenv(OTEL_OPT_IN, self.semconv_opt_in)
if self.capture_content is None:
monkeypatch.delenv(CAPTURE_CONTENT, raising=False)
else:
monkeypatch.setenv(CAPTURE_CONTENT, self.capture_content)
monkeypatch.setenv(
ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN, str(self.schema_version)
)
monkeypatch.setenv("ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS", "false")
# ---------------------------------------------------------------------------
# aclosing wrapping assertions.
# ---------------------------------------------------------------------------
@contextmanager
def aclosing_wrapping_assertions() -> Iterator[None]:
"""Context manager that asserts every async generator is wrapped in ``aclosing``.
The check uses ``gc.get_referrers`` on every async generator first
iterated within the block, which is expensive (~5 seconds per
scenario). Run this once per scenario rather than per parametrized
test case.
On exit the original ``sys`` async-gen hooks are restored.
"""
prev_firstiter, prev_finalizer = sys.get_asyncgen_hooks()
def wrapped_firstiter(coro: AsyncGenerator[object, object]):
if _is_async_context_manager():
if prev_firstiter:
prev_firstiter(coro)
return
assert any(
isinstance(referrer, aclosing)
or isinstance(indirect_referrer, aclosing)
for referrer in gc.get_referrers(coro)
# Some coroutines have a layer of indirection in Python 3.10
for indirect_referrer in gc.get_referrers(referrer)
), _no_aclosing_assertion_error(coro)
if prev_firstiter:
prev_firstiter(coro)
sys.set_asyncgen_hooks(wrapped_firstiter, prev_finalizer)
try:
yield
finally:
sys.set_asyncgen_hooks(prev_firstiter, prev_finalizer)
def _no_aclosing_assertion_error(coro: AsyncGenerator[object, object]) -> str:
first_iter_loc = ""
definition_loc = ""
if (f := inspect.currentframe()) and (f := f.f_back) and (f := f.f_back):
first_iter_loc = f'file "{f.f_code.co_filename}" line "{f.f_lineno}"'
if (ag_code := getattr(coro, "ag_code", None)) and isinstance(
ag_code, CodeType
):
definition_loc = (
f'file "{ag_code.co_filename}" line "{ag_code.co_firstlineno}"'
)
header_str = f'Async generator "{coro.__name__}" is not wrapped in aclosing'
first_iter_str = (
f"first iterated in {first_iter_loc}" if first_iter_loc else ""
)
definition_str = f"defined in {definition_loc}" if definition_loc else ""
instruction_str = """
Wrap the iteration in the following code snippet before iterating:
async with contextlib.aclosing(...) as agen:
async for ... as agen:
...
"""
return "\n".join(
part
for part in [
header_str,
first_iter_str,
definition_str,
instruction_str,
]
if part
)
def _is_async_context_manager() -> bool:
"""Checks if this function was invoked by contextlib.asynccontextmanager."""
frame = inspect.currentframe()
while frame:
if (
frame.f_code.co_name == "__aenter__"
and "contextlib" in frame.f_code.co_filename
):
return True
frame = frame.f_back
return False
@@ -0,0 +1,331 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from google.adk.agents.llm_agent import Agent
from google.adk.telemetry import tracing
from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
from google.genai.types import Part
from mcp import ClientSession as McpClientSession
from mcp import StdioServerParameters
from mcp.types import ListToolsResult
from mcp.types import PaginatedRequestParams
from mcp.types import Tool as McpTool
from opentelemetry.instrumentation.google_genai import GoogleGenAiSdkInstrumentor
from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
import pytest
from typing_extensions import override
from ..testing_utils import MockModel
from ..testing_utils import TestInMemoryRunner
from .functional_test_cases import ALL_CASES
from .functional_test_cases import EXPECTED_EXPERIMENTAL_SPAN_AND_EVENT_WITH_MCP
from .functional_test_helpers import aclosing_wrapping_assertions
from .functional_test_helpers import build_test_runner
from .functional_test_helpers import CAPTURE_CONTENT
from .functional_test_helpers import EXPERIMENTAL_OPT_IN
from .functional_test_helpers import FunctionalTestCase
from .functional_test_helpers import install_telemetry
from .functional_test_helpers import OTEL_OPT_IN
from .functional_test_helpers import run_agent_scenario
from .functional_test_helpers import SpanDigest
from .functional_test_helpers import TelemetryDigest
@pytest.mark.parametrize("case", ALL_CASES, ids=lambda c: c.test_id)
@pytest.mark.asyncio
async def test_telemetry_schema(
case: FunctionalTestCase,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Tests creation of spans/logs/metrics in an E2E runner invocation.
Asserts the entire telemetry schema (spans + attributes + per-span logs +
recorded metric points) matches the hand-written expected shape for the
given semconv + content-capture configuration.
"""
case.apply_env(monkeypatch)
span_exporter = InMemorySpanExporter()
log_exporter = InMemoryLogRecordExporter()
metric_reader = InMemoryMetricReader()
install_telemetry(monkeypatch, span_exporter, log_exporter, metric_reader)
await run_agent_scenario(build_test_runner())
digest = TelemetryDigest.build(
span_exporter.get_finished_spans(),
log_exporter.get_finished_logs(),
metric_reader.get_metrics_data(),
)
assert digest == case.expected
@pytest.mark.asyncio
async def test_async_generators_wrapped_in_aclosing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Asserts each async generator iterated by the scenario is wrapped in ``aclosing``.
Necessary because instrumentation utilizes contextvars, which run into
"ContextVar was created in a different Context" errors when a given
coroutine gets indeterminately suspended.
Kept as a single non-parametrized test because the underlying
``gc.get_referrers`` walk is expensive (~5 seconds per scenario).
"""
install_telemetry(
monkeypatch,
InMemorySpanExporter(),
InMemoryLogRecordExporter(),
InMemoryMetricReader(),
)
with aclosing_wrapping_assertions():
await run_agent_scenario(build_test_runner())
@pytest.mark.asyncio
async def test_exception_preserves_attributes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test when an exception occurs during tool execution, span attributes are still present on spans where they are expected."""
span_exporter = InMemorySpanExporter()
install_telemetry(
monkeypatch,
span_exporter,
InMemoryLogRecordExporter(),
InMemoryMetricReader(),
)
with pytest.raises(ValueError, match="This tool always fails"):
_ = await run_agent_scenario(build_test_runner(failing=True))
spans = span_exporter.get_finished_spans()
assert len(spans) > 1
assert all(
span.attributes is not None and len(span.attributes) > 0
for span in spans
if span.name != "invocation" # not expected to have attributes
)
@pytest.mark.asyncio
async def test_no_generate_content_for_gemini_model_when_already_instrumented(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Tests that generate_content span is not created if already instrumented."""
span_exporter = InMemorySpanExporter()
install_telemetry(
monkeypatch,
span_exporter,
InMemoryLogRecordExporter(),
InMemoryMetricReader(),
)
monkeypatch.setattr(
tracing,
"_instrumented_with_opentelemetry_instrumentation_google_genai",
lambda: True,
)
monkeypatch.setattr(
tracing,
"_is_gemini_agent",
lambda _: True,
)
_ = await run_agent_scenario(build_test_runner())
spans = span_exporter.get_finished_spans()
assert not any(span.name.startswith("generate_content") for span in spans)
def test_instrumented_with_opentelemetry_instrumentation_google_genai():
instrumentor = GoogleGenAiSdkInstrumentor()
assert (
not tracing._instrumented_with_opentelemetry_instrumentation_google_genai()
)
try:
instrumentor.instrument()
assert (
tracing._instrumented_with_opentelemetry_instrumentation_google_genai()
)
finally:
instrumentor.uninstrument()
assert (
not tracing._instrumented_with_opentelemetry_instrumentation_google_genai()
)
# ---------------------------------------------------------------------------
# MCP integration: telemetry adds zero ``list_tools()`` calls of its own.
#
# The standard ADK ↔ MCP integration path is:
#
# Agent(tools=[McpToolset(...)])
# → McpToolset.get_tools() ─ calls list_tools() ONCE, caches MCPTool list
# → BaseLlmFlow loop calls each MCPTool.process_llm_request, which
# materializes the tool's FunctionDeclaration into
# llm_request.config.tools.
#
# By the time the experimental semconv builder reads
# ``llm_request.config.tools``, MCP tools are ALREADY ``types.Tool``
# entries with ``function_declarations``. Because the builder is fully
# synchronous (it never calls ``list_tools()`` itself), the MCP server is
# queried EXACTLY ONCE per agent invocation regardless of which semconv
# (or capture mode) is active. These tests pin that contract AND verify
# the resolved tool definitions surface intact in the experimental
# telemetry.
#
# A ``_FakeMcpSession`` substitutes the live ``McpClientSession`` so the
# test doesn't need a running MCP server. ``McpToolset.create_session``
# is patched to hand it out instead of dialing ``StdioServerParameters``.
# ---------------------------------------------------------------------------
class _FakeMcpSession(McpClientSession):
"""Minimal ``McpClientSession`` stand-in with a counted ``list_tools()``.
Subclasses ``McpClientSession`` (and skips its real ``__init__``) so that
every ``isinstance(x, McpClientSession)`` check in ADK and in the MCP
Python client passes, without needing to wire up the underlying anyio
memory streams + peer process.
"""
def __init__( # pyright: ignore[reportMissingSuperCall]
self, *, tools: list[McpTool]
) -> None:
# Deliberately skip ``McpClientSession.__init__``: the real one wants
# live anyio streams + a peer process. ``isinstance`` checks still
# succeed, which is all ADK's MCP plumbing requires.
self._tools: list[McpTool] = tools
self.list_tools_call_count: int = 0
@override
async def list_tools(
self,
cursor: str | None = None,
*,
params: PaginatedRequestParams | None = None,
) -> ListToolsResult:
self.list_tools_call_count += 1
return ListToolsResult(tools=list(self._tools))
def _make_fake_mcp_toolset(
monkeypatch: pytest.MonkeyPatch, fake_session: _FakeMcpSession
) -> McpToolset:
"""Returns an ``McpToolset`` whose session manager hands out ``fake_session``.
Patches the toolset's ``MCPSessionManager`` so:
* ``create_session`` returns the fake (no socket / subprocess).
* ``close`` is a no-op (the fake holds no resources).
Connection params are nominally a stdio command but never actually
invoked because ``create_session`` is overridden.
"""
toolset = McpToolset(
connection_params=StdioConnectionParams(
server_params=StdioServerParameters(command="unused-by-test"),
)
)
async def _create_session(*_args, **_kwargs): # pyright: ignore[reportUnknownParameterType, reportMissingParameterType]
return fake_session
async def _close(*_args, **_kwargs): # pyright: ignore[reportUnknownParameterType, reportMissingParameterType]
return None
monkeypatch.setattr(
toolset._mcp_session_manager, "create_session", _create_session # pyright: ignore[reportPrivateUsage, reportUnknownArgumentType]
)
monkeypatch.setattr(toolset._mcp_session_manager, "close", _close) # pyright: ignore[reportPrivateUsage, reportUnknownArgumentType]
return toolset
def _build_mcp_test_runner(toolset: McpToolset) -> TestInMemoryRunner:
"""Builds a single-turn agent runner whose only tool source is ``toolset``.
Single-turn (one ``Part.from_text`` response) so the assertion on
``list_tools_call_count`` is unambiguous: exactly one agent invocation
is performed.
"""
mock_model = MockModel.create(
responses=[Part.from_text(text="text response")]
)
test_agent = Agent(
name="some_root_agent",
description="A sample root agent.",
instruction="you are helpful",
model=mock_model,
tools=[toolset],
)
return TestInMemoryRunner(node=test_agent)
@pytest.mark.asyncio
async def test_mcp_list_tools_called_once_under_experimental_semconv(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Experimental semconv: exactly one ``list_tools()`` call per invocation.
By the time the experimental semconv builder inspects
``llm_request.config.tools``, ``McpToolset`` has already materialized
each MCP tool into a ``FunctionDeclaration`` — so the synchronous
builder never has to (and never does) talk to the MCP server. The
MCP-resolved tool definition still surfaces in the experimental
telemetry intact, sourced from the ``FunctionDeclaration`` rather than
from a fresh ``list_tools()`` call.
"""
monkeypatch.setenv(OTEL_OPT_IN, EXPERIMENTAL_OPT_IN)
monkeypatch.setenv(CAPTURE_CONTENT, "span_and_event")
monkeypatch.setenv("ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS", "false")
span_exporter = InMemorySpanExporter()
log_exporter = InMemoryLogRecordExporter()
install_telemetry(
monkeypatch, span_exporter, log_exporter, InMemoryMetricReader()
)
fake_session = _FakeMcpSession(
tools=[
McpTool(
name="mcp_echo",
description="Echoes back its input.",
inputSchema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
)
]
)
toolset = _make_fake_mcp_toolset(monkeypatch, fake_session)
await run_agent_scenario(_build_mcp_test_runner(toolset))
assert fake_session.list_tools_call_count == 1
digest = SpanDigest.build(
span_exporter.get_finished_spans(),
log_exporter.get_finished_logs(),
)
assert digest == EXPECTED_EXPERIMENTAL_SPAN_AND_EVENT_WITH_MCP
@@ -0,0 +1,238 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
from typing import Optional
from unittest import mock
from google.adk.telemetry import google_cloud
from google.adk.telemetry.google_cloud import _DEFAULT_MTLS_TELEMETRY_TRACES_ENPOINT
from google.adk.telemetry.google_cloud import _DEFAULT_TELEMETRY_TRACES_ENPOINT
from google.adk.telemetry.google_cloud import _get_api_endpoint
from google.adk.telemetry.google_cloud import _get_gcp_span_exporter
from google.adk.telemetry.google_cloud import _use_client_cert_effective
from google.adk.telemetry.google_cloud import get_gcp_exporters
from google.adk.telemetry.google_cloud import get_gcp_resource
import google.auth.credentials
from google.auth.transport import mtls
from google.auth.transport import requests
from opentelemetry.exporter.otlp.proto.http import trace_exporter
import pytest
@pytest.mark.parametrize("enable_cloud_tracing", [True, False])
@pytest.mark.parametrize("enable_cloud_metrics", [True, False])
@pytest.mark.parametrize("enable_cloud_logging", [True, False])
def test_get_gcp_exporters(
enable_cloud_tracing: bool,
enable_cloud_metrics: bool,
enable_cloud_logging: bool,
monkeypatch: pytest.MonkeyPatch,
):
"""
Test initializing correct providers in setup_otel
when enabling telemetry via Google O11y.
"""
# Arrange.
# Mocking google.auth.default to improve the test time.
auth_mock = mock.MagicMock()
auth_mock.return_value = ("", "project-id")
monkeypatch.setattr(
"google.auth.default",
auth_mock,
)
monkeypatch.setattr(
"google.adk.telemetry.google_cloud._get_gcp_span_exporter",
lambda credentials: mock.MagicMock(),
)
monkeypatch.setattr(
"google.adk.telemetry.google_cloud._get_gcp_metrics_exporter",
lambda project_id: mock.MagicMock(),
)
monkeypatch.setattr(
"google.adk.telemetry.google_cloud._get_gcp_logs_exporter",
lambda project_id: mock.MagicMock(),
)
# Act.
otel_hooks = get_gcp_exporters(
enable_cloud_tracing=enable_cloud_tracing,
enable_cloud_metrics=enable_cloud_metrics,
enable_cloud_logging=enable_cloud_logging,
)
# Assert.
# If given telemetry type was enabled,
# the corresponding provider should be set.
assert len(otel_hooks.span_processors) == (1 if enable_cloud_tracing else 0)
assert len(otel_hooks.metric_readers) == (1 if enable_cloud_metrics else 0)
assert len(otel_hooks.log_record_processors) == (
1 if enable_cloud_logging else 0
)
@pytest.mark.parametrize("project_id_in_arg", ["project_id_in_arg", None])
@pytest.mark.parametrize("project_id_on_env", ["project_id_on_env", None])
def test_get_gcp_resource(
project_id_in_arg: Optional[str],
project_id_on_env: Optional[str],
monkeypatch: pytest.MonkeyPatch,
):
# Arrange.
if project_id_on_env is not None:
monkeypatch.setenv(
"OTEL_RESOURCE_ATTRIBUTES", f"gcp.project_id={project_id_on_env}"
)
# Act.
otel_resource = get_gcp_resource(project_id_in_arg)
# Assert.
expected_project_id = (
project_id_on_env
if project_id_on_env is not None
else project_id_in_arg
if project_id_in_arg is not None
else None
)
assert otel_resource is not None
assert (
otel_resource.attributes.get("gcp.project_id", None)
== expected_project_id
)
def test_get_gcp_resource_sets_standard_cloud_resource_id(
monkeypatch: pytest.MonkeyPatch,
):
# Arrange.
monkeypatch.setenv("GOOGLE_CLOUD_AGENT_ENGINE_ID", "1234567890")
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
# Act.
otel_resource = get_gcp_resource("my-project")
# Assert.
# The Agent Engine dashboard filters on the OTel-standard key.
assert otel_resource.attributes.get("cloud.resource_id") == (
"//aiplatform.googleapis.com/projects/my-project"
"/locations/us-central1/reasoningEngines/1234567890"
)
assert "cloud.resource.id" not in otel_resource.attributes
@mock.patch.object(mtls, "should_use_client_cert", autospec=True)
def test_use_client_cert_effective_from_mtls(mock_should_use):
mock_should_use.return_value = True
assert _use_client_cert_effective()
mock_should_use.return_value = False
assert not _use_client_cert_effective()
def test_use_client_cert_effective_from_env(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
with mock.patch.object(
mtls,
"should_use_client_cert",
autospec=True,
side_effect=AttributeError,
):
monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true")
assert _use_client_cert_effective()
monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false")
assert not _use_client_cert_effective()
# Test invalid value defaults to False
monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "maybe")
assert not _use_client_cert_effective()
assert (
"Environment variable `GOOGLE_API_USE_CLIENT_CERTIFICATE` must be"
" either `true` or `false`"
in caplog.text
)
@pytest.mark.parametrize(
"env_val, cert_source, expected",
[
("auto", lambda: b"cert", _DEFAULT_MTLS_TELEMETRY_TRACES_ENPOINT),
("auto", None, _DEFAULT_TELEMETRY_TRACES_ENPOINT),
("always", None, _DEFAULT_MTLS_TELEMETRY_TRACES_ENPOINT),
("never", lambda: b"cert", _DEFAULT_TELEMETRY_TRACES_ENPOINT),
("invalid", None, _DEFAULT_TELEMETRY_TRACES_ENPOINT),
],
)
def test_get_api_endpoint(
env_val,
cert_source,
expected,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
):
monkeypatch.setenv("GOOGLE_API_USE_MTLS_ENDPOINT", env_val)
if env_val == "invalid":
assert _get_api_endpoint(cert_source) == expected
assert (
"Environment variable `GOOGLE_API_USE_MTLS_ENDPOINT` must be one of"
in caplog.text
)
else:
assert _get_api_endpoint(cert_source) == expected
@mock.patch.object(requests, "AuthorizedSession", autospec=True)
@mock.patch(
"opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter",
autospec=True,
)
@mock.patch(
"google.adk.telemetry.google_cloud.BatchSpanProcessor", autospec=True
)
@mock.patch(
"google.adk.telemetry.google_cloud._use_client_cert_effective",
autospec=True,
)
@mock.patch(
"google.auth.transport.mtls.has_default_client_cert_source", autospec=True
)
@mock.patch(
"google.auth.transport.mtls.default_client_cert_source", autospec=True
)
def test_get_gcp_span_exporter_mtls(
mock_default_cert: mock.MagicMock,
mock_has_cert: mock.MagicMock,
mock_use_cert: mock.MagicMock,
mock_batch: mock.MagicMock,
mock_exporter: mock.MagicMock,
mock_session: mock.MagicMock,
):
credentials = mock.create_autospec(
google.auth.credentials.Credentials, instance=True
)
mock_use_cert.return_value = True
mock_has_cert.return_value = True
mock_default_cert.return_value = b"cert"
_get_gcp_span_exporter(credentials)
mock_session.assert_called_once_with(credentials=credentials)
mock_session.return_value.configure_mtls_channel.assert_called_once()
mock_exporter.assert_called_once_with(
session=mock_session.return_value,
endpoint=_DEFAULT_MTLS_TELEMETRY_TRACES_ENPOINT,
headers=None,
)
@@ -0,0 +1,82 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# pylint: disable=protected-access
import time
from unittest import mock
from google.adk.telemetry import _metrics
from opentelemetry import trace
def test_get_elapsed_s_span_none():
"""Tests fallback when span is None."""
start_time = 10.0
with mock.patch("time.monotonic", return_value=12.0):
elapsed = _metrics.get_elapsed_s(None, start_time)
assert elapsed == 2.0 # 12 - 10
def test_get_elapsed_s_span_valid():
"""Tests duration calculation with valid span times."""
mock_span = mock.MagicMock(spec=trace.Span)
mock_span.start_time = 1000000000 # 1s in ns
mock_span.end_time = 2000000000 # 2s in ns
elapsed = _metrics.get_elapsed_s(mock_span, time.monotonic())
assert elapsed == 1.0 # (2 - 1) s
def test_get_elapsed_s_span_missing_start():
"""Tests fallback when start_time is missing."""
mock_span = mock.MagicMock(spec=trace.Span)
del mock_span.start_time
mock_span.end_time = 2000000000
start_time = 10.0
with mock.patch("time.monotonic", return_value=12.0):
elapsed = _metrics.get_elapsed_s(mock_span, start_time)
assert elapsed == 2.0
def test_get_elapsed_s_span_missing_end():
"""Tests fallback when end_time is missing."""
mock_span = mock.MagicMock(spec=trace.Span)
mock_span.start_time = 1000000000
del mock_span.end_time
start_time = 10.0
with mock.patch("time.monotonic", return_value=12.0):
elapsed = _metrics.get_elapsed_s(mock_span, start_time)
assert elapsed == 2.0
def test_get_elapsed_s_span_non_int_start():
"""Tests fallback when start_time is not an integer."""
mock_span = mock.MagicMock(spec=trace.Span)
mock_span.start_time = 1000000000.0
mock_span.end_time = 2000000000
start_time = 10.0
with mock.patch("time.monotonic", return_value=12.0):
elapsed = _metrics.get_elapsed_s(mock_span, start_time)
assert elapsed == 2.0
def test_get_elapsed_s_span_non_int_end():
"""Tests fallback when end_time is not an integer."""
mock_span = mock.MagicMock(spec=trace.Span)
mock_span.start_time = 1000000000
mock_span.end_time = 2000000000.0
start_time = 10.0
with mock.patch("time.monotonic", return_value=12.0):
elapsed = _metrics.get_elapsed_s(mock_span, start_time)
assert elapsed == 2.0
+261
View File
@@ -0,0 +1,261 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# pylint: disable=protected-access
from unittest import mock
from google.adk.telemetry import _metrics
from google.genai import types
from opentelemetry import metrics
import pytest
@pytest.fixture(name="mock_meter_setup")
def _mock_meter_setup(monkeypatch):
"""Sets up mock meter and histograms for testing."""
mock_meter = mock.MagicMock()
agent_duration_hist = mock.MagicMock(spec=metrics.Histogram)
workflow_duration_hist = mock.MagicMock(spec=metrics.Histogram)
tool_duration_hist = mock.MagicMock(spec=metrics.Histogram)
client_duration_hist = mock.MagicMock(spec=metrics.Histogram)
client_token_usage_hist = mock.MagicMock(spec=metrics.Histogram)
agent_duration_hist.name = "agent_invocation_duration"
workflow_duration_hist.name = "workflow_invocation_duration"
tool_duration_hist.name = "tool_execution_duration"
client_duration_hist.name = "client_operation_duration"
client_token_usage_hist.name = "client_token_usage"
def create_histogram_side_effect(name, **_kwargs):
if name == "gen_ai.invoke_agent.duration":
return agent_duration_hist
elif name == "gen_ai.invoke_workflow.duration":
return workflow_duration_hist
elif name == "gen_ai.execute_tool.duration":
return tool_duration_hist
elif name == "gen_ai.client.operation.duration":
return client_duration_hist
elif name == "gen_ai.client.token.usage":
return client_token_usage_hist
raise ValueError(f"Unknown metric name: {name}")
mock_meter.create_histogram.side_effect = create_histogram_side_effect
# Re-initialize the module-level variables in _metrics with mocked histograms
monkeypatch.setattr(_metrics, "meter", mock_meter)
monkeypatch.setattr(
_metrics, "_agent_invocation_duration", agent_duration_hist
)
monkeypatch.setattr(
_metrics, "_workflow_invocation_duration", workflow_duration_hist
)
monkeypatch.setattr(_metrics, "_tool_execution_duration", tool_duration_hist)
monkeypatch.setattr(
_metrics, "_client_operation_duration", client_duration_hist
)
monkeypatch.setattr(_metrics, "_client_token_usage", client_token_usage_hist)
return {
"meter": mock_meter,
"agent_duration": agent_duration_hist,
"workflow_duration": workflow_duration_hist,
"tool_duration": tool_duration_hist,
"client_duration": client_duration_hist,
"client_token_usage": client_token_usage_hist,
}
def test_record_agent_invocation_duration(mock_meter_setup):
"""Tests record_agent_invocation_duration records correctly."""
_metrics.record_agent_invocation_duration(
"test_agent",
1.0,
)
agent_duration_hist = mock_meter_setup["agent_duration"]
agent_duration_hist.record.assert_called_once()
args, kwargs = agent_duration_hist.record.call_args
assert args[0] == 1.0
want_attributes = {"gen_ai.agent.name": "test_agent"}
assert kwargs["attributes"] == want_attributes
def test_record_agent_invocation_duration_with_error(mock_meter_setup):
"""Tests record_agent_invocation_duration records error correctly."""
test_error = ValueError("agent failed")
_metrics.record_agent_invocation_duration(
"test_agent",
1.0,
error=test_error,
)
agent_duration_hist = mock_meter_setup["agent_duration"]
agent_duration_hist.record.assert_called_once()
_, kwargs = agent_duration_hist.record.call_args
assert kwargs["attributes"]["error.type"] == "ValueError"
def test_record_workflow_invocation_duration_root(mock_meter_setup):
"""Tests record_workflow_invocation_duration omits nested for the root."""
_metrics.record_workflow_invocation_duration(
workflow_name="my_workflow",
elapsed_s=1.0,
nested=False,
)
hist = mock_meter_setup["workflow_duration"]
hist.record.assert_called_once()
args, kwargs = hist.record.call_args
assert args[0] == 1.0
assert kwargs["attributes"] == {
"gen_ai.operation.name": "invoke_workflow",
"gen_ai.workflow.name": "my_workflow",
}
def test_record_workflow_invocation_duration_nested_with_error(
mock_meter_setup,
):
"""Tests record_workflow_invocation_duration records nested + error."""
_metrics.record_workflow_invocation_duration(
workflow_name="nested_workflow",
elapsed_s=2.0,
nested=True,
error=ValueError("boom"),
)
hist = mock_meter_setup["workflow_duration"]
hist.record.assert_called_once()
_, kwargs = hist.record.call_args
assert kwargs["attributes"]["gen_ai.workflow.nested"] is True
assert kwargs["attributes"]["error.type"] == "ValueError"
def test_record_tool_execution_duration(mock_meter_setup):
"""Tests record_tool_execution_duration records correctly."""
_metrics.record_tool_execution_duration(
"test_tool",
"test_tool_type",
"test_agent",
0.5,
)
tool_duration_hist = mock_meter_setup["tool_duration"]
tool_duration_hist.record.assert_called_once()
args, kwargs = tool_duration_hist.record.call_args
assert args[0] == 0.5
want_attributes = {
"gen_ai.agent.name": "test_agent",
"gen_ai.tool.name": "test_tool",
"gen_ai.tool.type": "test_tool_type",
}
assert kwargs["attributes"] == want_attributes
def test_record_tool_execution_duration_with_error(mock_meter_setup):
"""Tests record_tool_execution_duration records error correctly."""
test_error = ValueError("tool failed")
_metrics.record_tool_execution_duration(
"test_tool",
"test_tool_type",
"test_agent",
0.5,
error=test_error,
)
tool_duration_hist = mock_meter_setup["tool_duration"]
tool_duration_hist.record.assert_called_once()
_, kwargs = tool_duration_hist.record.call_args
assert kwargs["attributes"]["error.type"] == "ValueError"
def test_record_client_operation_duration(mock_meter_setup):
"""Tests record_client_operation_duration records correctly."""
llm_request = mock.MagicMock(
contents=[types.Content(parts=[types.Part(text="hello")])]
)
response = mock.MagicMock(
content=types.Content(parts=[types.Part(text="hello response")])
)
_metrics.record_client_operation_duration(
agent_name="test_agent",
elapsed_s=0.1,
llm_request=llm_request,
responses=[response],
)
client_duration_hist = mock_meter_setup["client_duration"]
client_duration_hist.record.assert_called_once()
args, kwargs = client_duration_hist.record.call_args
assert args[0] == 0.1
want_attributes = {
"gen_ai.agent.name": "test_agent",
"gen_ai.operation.name": "generate_content",
"gen_ai.provider.name": "gemini",
"gen_ai.request.model": llm_request.model,
"gen_ai.response.model": response.model_version,
}
assert kwargs["attributes"] == want_attributes
def test_record_client_token_usage(mock_meter_setup):
"""Tests record_client_token_usage records correctly under different usage conditions."""
llm_request = mock.MagicMock(
contents=[types.Content(parts=[types.Part(text="hello")])],
model="test-model",
)
response = mock.MagicMock(
content=types.Content(parts=[types.Part(text="hello response")]),
model_version="test-model-v1",
usage_metadata=types.GenerateContentResponseUsageMetadata(
prompt_token_count=20,
candidates_token_count=30,
tool_use_prompt_token_count=5,
thoughts_token_count=10,
),
)
_metrics.record_client_token_usage(
agent_name="test_agent",
llm_request=llm_request,
responses=[response],
)
client_token_usage_hist = mock_meter_setup["client_token_usage"]
assert client_token_usage_hist.record.call_count == 2
base_attributes = {
"gen_ai.agent.name": "test_agent",
"gen_ai.operation.name": "generate_content",
"gen_ai.provider.name": "gemini",
"gen_ai.request.model": "test-model",
"gen_ai.response.model": "test-model-v1",
}
input_call = None
output_call = None
for args, kwargs in client_token_usage_hist.record.call_args_list:
token_type = kwargs.get("attributes", {}).get("gen_ai.token.type")
if token_type == "input":
input_call = (args, kwargs)
elif token_type == "output":
output_call = (args, kwargs)
assert input_call is not None, "Missing 'input' token usage record"
assert output_call is not None, "Missing 'output' token usage record"
# Verify input tokens (prompt_token_count + tool_use_prompt_token_count)
assert input_call[0][0] == 25
assert input_call[1]["attributes"] == base_attributes | {
"gen_ai.token.type": "input"
}
# Verify output tokens (candidates_token_count + thoughts_token_count)
assert output_call[0][0] == 40
assert output_call[1]["attributes"] == base_attributes | {
"gen_ai.token.type": "output"
}
@@ -0,0 +1,197 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from typing import TYPE_CHECKING
from google.adk.telemetry import tracing
from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
import pytest
from .functional_node_test_cases import ALL_NODE_CASES
from .functional_test_helpers import aclosing_wrapping_assertions
from .functional_test_helpers import install_telemetry
from .functional_test_helpers import run_node_scenario
from .functional_test_helpers import TelemetryDigest
if TYPE_CHECKING:
from google.adk.events.event import Event
from opentelemetry.sdk.trace import ReadableSpan
from .functional_test_helpers import FunctionalTestCase
@pytest.mark.parametrize('case', ALL_NODE_CASES, ids=lambda c: c.test_id)
@pytest.mark.asyncio
async def test_telemetry_schema(
case: FunctionalTestCase,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Tests creation of multiple spans/logs in an E2E runner invocation with a
workflow.
Asserts the entire telemetry schema (spans + attributes + per-span logs)
matches the hand-written expected shape for the given semconv +
content-capture configuration.
"""
case.apply_env(monkeypatch)
span_exporter = InMemorySpanExporter()
log_exporter = InMemoryLogRecordExporter()
metric_reader = InMemoryMetricReader()
install_telemetry(monkeypatch, span_exporter, log_exporter, metric_reader)
events = await run_node_scenario()
spans = span_exporter.get_finished_spans()
digest = TelemetryDigest.build(
spans, log_exporter.get_finished_logs(), metric_reader.get_metrics_data()
)
assert digest == case.expected
_verify_associated_events(spans, events)
@pytest.mark.asyncio
async def test_async_generators_wrapped_in_aclosing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Asserts each async generator iterated by the scenario is wrapped in ``aclosing``.
Necessary because instrumentation utilizes contextvars, which run into
"ContextVar was created in a different Context" errors when a given
coroutine gets indeterminately suspended.
Kept as a single non-parametrized test because the underlying
``gc.get_referrers`` walk is expensive (~5 seconds per scenario).
"""
install_telemetry(
monkeypatch,
InMemorySpanExporter(),
InMemoryLogRecordExporter(),
InMemoryMetricReader(),
)
with aclosing_wrapping_assertions():
_ = await run_node_scenario()
def _verify_associated_events(
spans: tuple[ReadableSpan, ...], events: list[Event]
):
def _nodelike_name(span: ReadableSpan) -> str:
for prefix in ['invoke_node ', 'invoke_workflow ', 'invoke_agent ']:
if span.name.startswith(prefix):
return span.name.replace(prefix, '')
return ''
def _emitting_node_name(event: Event) -> str:
# Strip out
# 1. Path except for the last node (everything before "/")
# 2. Retry count (everything after "@")
return event.node_info.path.split('/')[-1].split('@')[0]
events_by_id = {event.id: event for event in events}
for span in spans:
if not span.attributes:
continue
associated_ids = span.attributes.get(
'gcp.vertex.agent.associated_event_ids', None
)
if associated_ids is None:
continue
assert isinstance(associated_ids, tuple)
assert len(associated_ids) > 0, f'Span name {span.name} emitted no events'
for event_id in associated_ids:
event = events_by_id[str(event_id)]
assert _nodelike_name(span) == _emitting_node_name(event)
@pytest.mark.asyncio
async def test_exception_preserves_attributes(
monkeypatch: pytest.MonkeyPatch,
):
"""Test when an exception occurs during tool execution, span attributes are still present on spans where they are expected."""
span_exporter = InMemorySpanExporter()
install_telemetry(
monkeypatch,
span_exporter,
InMemoryLogRecordExporter(),
InMemoryMetricReader(),
)
captured_events: list[Event] = []
with pytest.raises(ValueError, match='This tool always fails'):
await run_node_scenario(failing=True, event_sink=captured_events)
# Assert
spans = span_exporter.get_finished_spans()
_verify_associated_events(spans, captured_events)
spans_by_name = {span.name: span for span in spans}
assert 'execute_tool some_tool' in spans_by_name
tool_span = spans_by_name['execute_tool some_tool']
attrs = dict(tool_span.attributes)
# Dynamic ID
tool_call_id = attrs.get('gen_ai.tool.call.id')
assert dict(tool_span.attributes) == {
'gen_ai.operation.name': 'execute_tool',
'gen_ai.tool.name': 'some_tool',
'gen_ai.tool.description': 'A sample tool.',
'gen_ai.tool.type': 'FunctionTool',
'error.type': 'ValueError',
'gcp.vertex.agent.llm_request': '{}',
'gcp.vertex.agent.llm_response': '{}',
'gcp.vertex.agent.tool_call_args': '{"arg1": "val1"}',
'gen_ai.tool.call.id': tool_call_id,
'gcp.vertex.agent.tool_response': '{"result": "<not specified>"}',
}
@pytest.mark.asyncio
async def test_no_generate_content_for_gemini_model_when_already_instrumented(
monkeypatch: pytest.MonkeyPatch,
):
"""Tests that generate_content span is not created if already instrumented."""
span_exporter = InMemorySpanExporter()
install_telemetry(
monkeypatch,
span_exporter,
InMemoryLogRecordExporter(),
InMemoryMetricReader(),
)
# Arrange
monkeypatch.setattr(
tracing,
'_instrumented_with_opentelemetry_instrumentation_google_genai',
lambda: True,
)
monkeypatch.setattr(
tracing,
'_is_gemini_agent',
lambda _: True,
)
_ = await run_node_scenario()
# Assert
spans = span_exporter.get_finished_spans()
assert not any(span.name.startswith('generate_content') for span in spans)
+119
View File
@@ -0,0 +1,119 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
from unittest import mock
from google.adk.telemetry.setup import maybe_set_otel_providers
import pytest
@pytest.fixture
def mock_os_environ():
initial_env = os.environ.copy()
with mock.patch.dict(os.environ, initial_env, clear=False) as m:
yield m
@pytest.mark.parametrize(
"env_vars, should_setup_trace, should_setup_metrics, should_setup_logs",
[
(
{"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "some-endpoint"},
True,
False,
False,
),
(
{"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": "some-endpoint"},
False,
True,
False,
),
(
{"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": "some-endpoint"},
False,
False,
True,
),
(
{
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "some-endpoint",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": "some-endpoint",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": "some-endpoint",
},
True,
True,
True,
),
(
{"OTEL_EXPORTER_OTLP_ENDPOINT": "some-endpoint"},
True,
True,
True,
),
],
)
def test_maybe_set_otel_providers(
env_vars: dict[str, str],
should_setup_trace: bool,
should_setup_metrics: bool,
should_setup_logs: bool,
monkeypatch: pytest.MonkeyPatch,
mock_os_environ, # pylint: disable=unused-argument,redefined-outer-name
):
"""
Test initializing correct providers in setup_otel
when providing OTel env variables.
"""
# Arrange.
for k, v in env_vars.items():
monkeypatch.setenv(k, v)
trace_provider_mock = mock.MagicMock()
monkeypatch.setattr(
"opentelemetry.trace.set_tracer_provider",
trace_provider_mock,
)
meter_provider_mock = mock.MagicMock()
monkeypatch.setattr(
"opentelemetry.metrics.set_meter_provider",
meter_provider_mock,
)
logs_provider_mock = mock.MagicMock()
monkeypatch.setattr(
"opentelemetry._logs.set_logger_provider",
logs_provider_mock,
)
monkeypatch.setattr(
"google.adk.telemetry.setup._get_otel_span_exporter",
lambda: mock.MagicMock(),
)
monkeypatch.setattr(
"google.adk.telemetry.setup._get_otel_metrics_exporter",
lambda: mock.MagicMock(),
)
monkeypatch.setattr(
"google.adk.telemetry.setup._get_otel_logs_exporter",
lambda: mock.MagicMock(),
)
# Act.
maybe_set_otel_providers()
# Assert.
# If given telemetry type was enabled,
# the corresponding provider should be set.
assert trace_provider_mock.call_count == (1 if should_setup_trace else 0)
assert meter_provider_mock.call_count == (1 if should_setup_metrics else 0)
assert logs_provider_mock.call_count == (1 if should_setup_logs else 0)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,462 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import json
from pathlib import Path
from google.adk.telemetry.sqlite_span_exporter import SqliteSpanExporter
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export import SpanExportResult
from opentelemetry.trace import SpanContext
from opentelemetry.trace import TraceFlags
from opentelemetry.trace import TraceState
def _create_span(
*,
span_id: int = 0x00000000000ABC12,
trace_id: int = 0x000000000000000000000000000DEF45,
parent_span_id: int | None = None,
name: str = "test_span",
attributes: dict | None = None,
start_time: int = 1000,
end_time: int = 2000,
) -> ReadableSpan:
"""Helper to create ReadableSpan instances for testing."""
context = SpanContext(
trace_id=trace_id,
span_id=span_id,
is_remote=False,
trace_flags=TraceFlags(TraceFlags.SAMPLED),
trace_state=TraceState(),
)
parent = None
if parent_span_id is not None:
parent = SpanContext(
trace_id=trace_id,
span_id=parent_span_id,
is_remote=False,
trace_flags=TraceFlags(TraceFlags.SAMPLED),
trace_state=TraceState(),
)
return ReadableSpan(
name=name,
context=context,
parent=parent,
attributes=attributes or {},
start_time=start_time,
end_time=end_time,
)
def test_export_single_span_returns_success(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
span = _create_span(
name="test_operation",
attributes={"gcp.vertex.agent.session_id": "session-123"},
)
result = exporter.export([span])
assert result == SpanExportResult.SUCCESS
assert db_path.exists()
def test_export_empty_list_returns_success(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
result = exporter.export([])
assert result == SpanExportResult.SUCCESS
def test_get_all_spans_for_session_returns_matching_spans(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
span1 = _create_span(
span_id=0x111,
trace_id=0xAAA111, # Different trace for session-123
attributes={"gcp.vertex.agent.session_id": "session-123"},
name="span1",
)
span2 = _create_span(
span_id=0x222,
trace_id=0xAAA222, # Different trace for session-123
attributes={"gcp.vertex.agent.session_id": "session-123"},
name="span2",
)
span3 = _create_span(
span_id=0x333,
trace_id=0xBBB333, # Different trace for session-456
attributes={"gcp.vertex.agent.session_id": "session-456"},
name="span3",
)
exporter.export([span1, span2, span3])
result = exporter.get_all_spans_for_session("session-123")
assert len(result) == 2
names = [span.name for span in result]
assert "span1" in names
assert "span2" in names
assert "span3" not in names
def test_get_all_spans_for_session_includes_sibling_spans_without_session_id(
tmp_path,
):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
# Parent span without session_id (e.g., invocation span)
parent_span = _create_span(
span_id=0x100,
trace_id=0xAAA,
name="invocation",
attributes={}, # No session_id
)
# Child span with session_id
child_span = _create_span(
span_id=0x200,
trace_id=0xAAA, # Same trace
parent_span_id=0x100,
name="call_llm",
attributes={"gcp.vertex.agent.session_id": "session-789"},
)
# Sibling span without session_id (should be included)
sibling_span = _create_span(
span_id=0x300,
trace_id=0xAAA, # Same trace
parent_span_id=0x100,
name="tool_call",
attributes={}, # No session_id
)
# Unrelated span with different trace_id (should not be included)
unrelated_span = _create_span(
span_id=0x400,
trace_id=0xBBB, # Different trace
name="unrelated",
attributes={},
)
exporter.export([parent_span, child_span, sibling_span, unrelated_span])
result = exporter.get_all_spans_for_session("session-789")
assert len(result) == 3
names = [span.name for span in result]
assert "invocation" in names
assert "call_llm" in names
assert "tool_call" in names
assert "unrelated" not in names
def test_get_all_spans_for_unknown_session_returns_empty_list(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
span = _create_span(
attributes={"gcp.vertex.agent.session_id": "session-123"},
)
exporter.export([span])
result = exporter.get_all_spans_for_session("unknown-session")
assert result == []
def test_round_trip_preserves_span_attributes(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
original_attributes = {
"gcp.vertex.agent.session_id": "session-123",
"gcp.vertex.agent.invocation_id": "invocation-456",
"gen_ai.conversation.id": "conv-789",
"custom.attribute": "test_value",
"numeric.value": 42,
"boolean.value": True,
"list.value": [1, 2, 3],
"dict.value": {"nested": "data"},
}
original_span = _create_span(
span_id=0x12345678,
trace_id=0xABCDEF123456789,
name="test_operation",
attributes=original_attributes,
start_time=1000000,
end_time=2000000,
)
exporter.export([original_span])
retrieved_spans = exporter.get_all_spans_for_session("session-123")
assert len(retrieved_spans) == 1
retrieved = retrieved_spans[0]
assert retrieved.name == "test_operation"
assert retrieved.context.span_id == 0x12345678
assert retrieved.context.trace_id == 0xABCDEF123456789
assert retrieved.start_time == 1000000
assert retrieved.end_time == 2000000
assert retrieved.attributes == original_attributes
def test_spans_with_parent_context_exported_correctly(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
parent_span = _create_span(
span_id=0xAAA,
trace_id=0x123,
name="parent",
attributes={"gcp.vertex.agent.session_id": "session-001"},
)
child_span = _create_span(
span_id=0xBBB,
trace_id=0x123,
parent_span_id=0xAAA,
name="child",
attributes={"gcp.vertex.agent.session_id": "session-001"},
)
exporter.export([parent_span, child_span])
retrieved_spans = exporter.get_all_spans_for_session("session-001")
assert len(retrieved_spans) == 2
# Find child span in results
child = next(s for s in retrieved_spans if s.name == "child")
assert child.parent is not None
assert child.parent.span_id == 0xAAA
assert child.parent.trace_id == 0x123
# Find parent span in results
parent = next(s for s in retrieved_spans if s.name == "parent")
assert parent.parent is None
def test_shutdown_closes_connection(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
# Create a span to ensure connection is open
span = _create_span()
exporter.export([span])
# Verify connection exists
assert exporter._conn is not None
exporter.shutdown()
# Verify connection is closed
assert exporter._conn is None
def test_force_flush_returns_true(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
result = exporter.force_flush()
assert result is True
# Also test with timeout parameter
result_with_timeout = exporter.force_flush(timeout_millis=5000)
assert result_with_timeout is True
def test_export_handles_spans_with_none_attributes(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
span = _create_span(attributes=None)
result = exporter.export([span])
assert result == SpanExportResult.SUCCESS
# Verify the span was stored correctly
rows = exporter._query("SELECT attributes_json FROM spans", [])
assert len(rows) == 1
attributes_json = rows[0]["attributes_json"]
assert json.loads(attributes_json) == {}
def test_duplicate_span_id_replaces_previous_row(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
# Export first version of span
span1 = _create_span(
span_id=0x999,
name="first_version",
attributes={"version": 1, "gcp.vertex.agent.session_id": "session-dup"},
)
exporter.export([span1])
# Export second version with same span_id
span2 = _create_span(
span_id=0x999,
name="second_version",
attributes={"version": 2, "gcp.vertex.agent.session_id": "session-dup"},
)
exporter.export([span2])
# Verify only one row exists with updated data
retrieved_spans = exporter.get_all_spans_for_session("session-dup")
assert len(retrieved_spans) == 1
assert retrieved_spans[0].name == "second_version"
assert retrieved_spans[0].attributes["version"] == 2
def test_non_serializable_attributes_use_fallback(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
# Create a non-serializable object
class NonSerializable:
pass
attributes = {
"gcp.vertex.agent.session_id": "session-nonser",
"normal_attr": "value",
"non_serializable": NonSerializable(),
}
span = _create_span(attributes=attributes)
result = exporter.export([span])
assert result == SpanExportResult.SUCCESS
# Verify the span was stored and non-serializable attribute has fallback
retrieved_spans = exporter.get_all_spans_for_session("session-nonser")
assert len(retrieved_spans) == 1
assert retrieved_spans[0].attributes["normal_attr"] == "value"
assert (
retrieved_spans[0].attributes["non_serializable"] == "<not serializable>"
)
def test_export_multiple_spans_in_batch(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
spans = [
_create_span(
span_id=i,
name=f"span_{i}",
attributes={"gcp.vertex.agent.session_id": "batch-session"},
)
for i in range(10)
]
result = exporter.export(spans)
assert result == SpanExportResult.SUCCESS
retrieved_spans = exporter.get_all_spans_for_session("batch-session")
assert len(retrieved_spans) == 10
names = {span.name for span in retrieved_spans}
assert names == {f"span_{i}" for i in range(10)}
def test_export_with_alternative_session_id_attribute(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
# Test using gen_ai.conversation.id as fallback for session_id
span = _create_span(
attributes={"gen_ai.conversation.id": "conv-session-123"},
)
exporter.export([span])
# Should be queryable by the conversation id
result = exporter.get_all_spans_for_session("conv-session-123")
assert len(result) == 1
assert result[0].attributes["gen_ai.conversation.id"] == "conv-session-123"
def test_deserialize_handles_invalid_json(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
# Manually insert a row with invalid JSON
conn = exporter._get_connection()
conn.execute(
"INSERT INTO spans (span_id, trace_id, name, attributes_json) VALUES (?,"
" ?, ?, ?)",
("abc123", "def456", "test", "not valid json"),
)
conn.commit()
# Try to retrieve the span - should not raise, but attributes should be empty
rows = exporter._query("SELECT * FROM spans", [])
span = exporter._row_to_readable_span(rows[0])
assert span.name == "test"
assert span.attributes == {}
def test_get_spans_ordered_by_start_time(tmp_path):
db_path = tmp_path / "test.db"
exporter = SqliteSpanExporter(db_path=str(db_path))
# Create spans with different start times
spans = [
_create_span(
span_id=0x300,
start_time=3000,
attributes={"gcp.vertex.agent.session_id": "session-order"},
),
_create_span(
span_id=0x100,
start_time=1000,
attributes={"gcp.vertex.agent.session_id": "session-order"},
),
_create_span(
span_id=0x200,
start_time=2000,
attributes={"gcp.vertex.agent.session_id": "session-order"},
),
]
exporter.export(spans)
result = exporter.get_all_spans_for_session("session-order")
# Verify spans are ordered by start_time
assert len(result) == 3
assert result[0].context.span_id == 0x100
assert result[1].context.span_id == 0x200
assert result[2].context.span_id == 0x300
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,221 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from google.adk.telemetry import _token_usage
from google.genai import types
import pytest
@pytest.fixture(name="usage_metadata")
def fixture_usage_metadata() -> types.GenerateContentResponseUsageMetadata:
"""Provides a baseline GenerateContentResponseUsageMetadata fixture with all token counts initialized to None."""
m = types.GenerateContentResponseUsageMetadata()
m.prompt_token_count = None
m.tool_use_prompt_token_count = None
m.candidates_token_count = None
m.thoughts_token_count = None
m.cached_content_token_count = None
return m
def test_input_token_count_all_present(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests input_token_count when all components are present."""
usage_metadata.prompt_token_count = 10
usage_metadata.tool_use_prompt_token_count = 5
token_usage = _token_usage.TokenUsage(usage_metadata)
assert token_usage.input_token_count == 15
def test_input_token_count_only_prompt(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests input_token_count when only prompt_token_count is present."""
usage_metadata.prompt_token_count = 10
usage_metadata.tool_use_prompt_token_count = None
token_usage = _token_usage.TokenUsage(usage_metadata)
assert token_usage.input_token_count == 10
def test_input_token_count_only_tool(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests input_token_count when only tool_use_prompt_token_count is present."""
usage_metadata.prompt_token_count = None
usage_metadata.tool_use_prompt_token_count = 5
token_usage = _token_usage.TokenUsage(usage_metadata)
assert token_usage.input_token_count == 5
def test_input_token_count_none(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests input_token_count when all components are None."""
usage_metadata.prompt_token_count = None
usage_metadata.tool_use_prompt_token_count = None
token_usage = _token_usage.TokenUsage(usage_metadata)
assert token_usage.input_token_count is None
def test_input_token_count_zero(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests input_token_count when all components are zero."""
usage_metadata.prompt_token_count = 0
usage_metadata.tool_use_prompt_token_count = 0
token_usage = _token_usage.TokenUsage(usage_metadata)
assert token_usage.input_token_count == 0
def test_input_token_count_metadata_none():
"""Tests input_token_count when usage_metadata is None."""
token_usage = _token_usage.TokenUsage(None)
assert token_usage.input_token_count is None
def test_input_token_count_missing_tool_use_attr():
"""Tests input_token_count when tool_use_prompt_token_count is missing."""
token_usage = _token_usage.TokenUsage(
types.GenerateContentResponseUsageMetadata(prompt_token_count=10)
)
assert token_usage.input_token_count == 10
def test_output_token_count_all_present(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests output_token_count when all components are present."""
usage_metadata.candidates_token_count = 20
usage_metadata.thoughts_token_count = 8
token_usage = _token_usage.TokenUsage(usage_metadata)
assert token_usage.output_token_count == 28
def test_output_token_count_only_candidates(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests output_token_count when only candidates_token_count is present."""
usage_metadata.candidates_token_count = 20
usage_metadata.thoughts_token_count = None
token_usage = _token_usage.TokenUsage(usage_metadata)
assert token_usage.output_token_count == 20
def test_output_token_count_only_thoughts(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests output_token_count when only thoughts_token_count is present."""
usage_metadata.candidates_token_count = None
usage_metadata.thoughts_token_count = 8
token_usage = _token_usage.TokenUsage(usage_metadata)
assert token_usage.output_token_count == 8
def test_output_token_count_none(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests output_token_count when all components are None."""
usage_metadata.candidates_token_count = None
usage_metadata.thoughts_token_count = None
token_usage = _token_usage.TokenUsage(usage_metadata)
assert token_usage.output_token_count is None
def test_output_token_count_zero(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests output_token_count when all components are zero."""
usage_metadata.candidates_token_count = 0
usage_metadata.thoughts_token_count = 0
token_usage = _token_usage.TokenUsage(usage_metadata)
assert token_usage.output_token_count == 0
def test_output_token_count_metadata_none():
"""Tests output_token_count when usage_metadata is None."""
token_usage = _token_usage.TokenUsage(None)
assert token_usage.output_token_count is None
def test_to_attributes_full(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests to_attributes with all attributes present."""
usage_metadata.prompt_token_count = 10
usage_metadata.tool_use_prompt_token_count = 5
usage_metadata.candidates_token_count = 20
usage_metadata.thoughts_token_count = 8
usage_metadata.cached_content_token_count = 100
token_usage = _token_usage.TokenUsage(usage_metadata)
attrs = token_usage.to_attributes()
assert attrs[_token_usage.GEN_AI_USAGE_INPUT_TOKENS] == 15
assert attrs[_token_usage.GEN_AI_USAGE_OUTPUT_TOKENS] == 28
assert attrs[_token_usage.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS] == 100
assert attrs[_token_usage.GEN_AI_USAGE_REASONING_OUTPUT_TOKENS] == 8
def test_to_attributes_partial(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests to_attributes with only some attributes present."""
usage_metadata.prompt_token_count = 10
usage_metadata.tool_use_prompt_token_count = None
usage_metadata.candidates_token_count = None
usage_metadata.thoughts_token_count = None
usage_metadata.cached_content_token_count = None
token_usage = _token_usage.TokenUsage(usage_metadata)
attrs = token_usage.to_attributes()
assert attrs[_token_usage.GEN_AI_USAGE_INPUT_TOKENS] == 10
assert _token_usage.GEN_AI_USAGE_OUTPUT_TOKENS not in attrs
assert _token_usage.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS not in attrs
assert _token_usage.GEN_AI_USAGE_REASONING_OUTPUT_TOKENS not in attrs
def test_to_attributes_metadata_none():
"""Tests to_attributes when usage_metadata is None."""
token_usage = _token_usage.TokenUsage(None)
assert token_usage.to_attributes() == {}
def test_to_attributes_with_zeros(
usage_metadata: types.GenerateContentResponseUsageMetadata,
):
"""Tests to_attributes when all attributes are zero."""
usage_metadata.prompt_token_count = 0
usage_metadata.tool_use_prompt_token_count = 0
usage_metadata.candidates_token_count = 0
usage_metadata.thoughts_token_count = 0
usage_metadata.cached_content_token_count = 0
token_usage = _token_usage.TokenUsage(usage_metadata)
attrs = token_usage.to_attributes()
assert attrs[_token_usage.GEN_AI_USAGE_INPUT_TOKENS] == 0
assert attrs[_token_usage.GEN_AI_USAGE_OUTPUT_TOKENS] == 0
assert attrs[_token_usage.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS] == 0
assert attrs[_token_usage.GEN_AI_USAGE_REASONING_OUTPUT_TOKENS] == 0
def test_to_attributes_missing_optional_attrs():
"""Tests to_attributes when optional attributes are missing from metadata object."""
token_usage = _token_usage.TokenUsage(
types.GenerateContentResponseUsageMetadata(
prompt_token_count=10, candidates_token_count=20
)
)
attrs = token_usage.to_attributes()
assert attrs[_token_usage.GEN_AI_USAGE_INPUT_TOKENS] == 10
assert attrs[_token_usage.GEN_AI_USAGE_OUTPUT_TOKENS] == 20