Files
wehub-resource-sync ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

1046 lines
40 KiB
Python

# 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.
"""Tests for per-request telemetry configuration overrides."""
from __future__ import annotations
import asyncio
from typing import Optional
from google.adk.agents.llm_agent import Agent
from google.adk.agents.run_config import RunConfig
from google.adk.models.llm_response import LlmResponse
from google.adk.telemetry import ContentCapturingMode
from google.adk.telemetry import TelemetryConfig
from google.adk.telemetry import tracing
from google.adk.telemetry._experimental_semconv import set_operation_details_common_attributes
from google.adk.telemetry.context import ADK_TELEMETRY_IGNORE_RUN_CONFIG
from google.adk.telemetry.tracing import trace_inference_result
from google.genai.types import Part
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from pydantic import ValidationError
import pytest
from ..testing_utils import InMemoryRunner
from ..testing_utils import MockModel
from ..testing_utils import UserContent
_ENV_EXPERIMENTAL = 'OTEL_SEMCONV_STABILITY_OPT_IN'
_ENV_CAPTURE = 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'
_ENV_ADK_SPAN_CAPTURE = 'ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS'
_ENV_ADMIN_LOCK = ADK_TELEMETRY_IGNORE_RUN_CONFIG
_ALL_TELEMETRY_ENV_VARS = (
_ENV_EXPERIMENTAL,
_ENV_CAPTURE,
_ENV_ADK_SPAN_CAPTURE,
_ENV_ADMIN_LOCK,
)
def _set_env(monkeypatch: pytest.MonkeyPatch, **env: Optional[str]) -> None:
"""Applies a clean telemetry env: unset everything, then set the given vars.
Starting from a known-empty state keeps each parametrized case hermetic
regardless of what the host environment happens to export.
"""
for name in _ALL_TELEMETRY_ENV_VARS:
monkeypatch.delenv(name, raising=False)
for name, value in env.items():
if value is not None:
monkeypatch.setenv(name, value)
def test_telemetry_config_is_frozen():
"""Frozen TelemetryConfig rejects mutation after construction."""
cfg = TelemetryConfig(genai_semconv_stability_opt_in='experimental')
with pytest.raises(ValidationError):
cfg.genai_semconv_stability_opt_in = 'stable' # type: ignore[misc]
# ---------------------------------------------------------------------------
# Construction truth table for ``TelemetryConfig`` itself (no env vars, no
# decision functions). Covers the cartesian product of the two fields'
# accepted/rejected values: every valid combination must construct and
# preserve its field values; every invalid value must raise ValidationError.
# ---------------------------------------------------------------------------
# Accepted values for each field. ``None`` means "field left at default".
_VALID_OPT_IN_VALUES = (None, 'stable', 'experimental')
_VALID_CAPTURE_VALUES = (
None,
ContentCapturingMode.NO_CONTENT,
ContentCapturingMode.EVENT_ONLY,
ContentCapturingMode.SPAN_ONLY,
ContentCapturingMode.SPAN_AND_EVENT,
)
# Full cartesian product of valid field values.
_VALID_CONSTRUCTION_TABLE = [
(opt_in, capture)
for opt_in in _VALID_OPT_IN_VALUES
for capture in _VALID_CAPTURE_VALUES
]
@pytest.mark.parametrize('opt_in,capture', _VALID_CONSTRUCTION_TABLE)
def test_telemetry_config_construction_accepts_valid_combinations(
opt_in: Optional[str],
capture: Optional[ContentCapturingMode],
):
"""Every valid (opt_in, capture) pair constructs and round-trips its fields."""
cfg = TelemetryConfig(
genai_semconv_stability_opt_in=opt_in,
capture_message_content=capture,
)
assert cfg.genai_semconv_stability_opt_in == opt_in
assert cfg.capture_message_content == capture
@pytest.mark.parametrize('member', list(ContentCapturingMode))
def test_telemetry_config_construction_coerces_capture_member_value_str(
member: ContentCapturingMode,
):
"""A ``ContentCapturingMode`` value string coerces to the enum member.
pydantic accepts the enum member's underlying value (e.g. ``'EVENT_ONLY'``)
and coerces it to the member, mirroring the env-var path which accepts the
matching uppercase string. Pinned so this leniency stays intentional.
"""
cfg = TelemetryConfig(capture_message_content=member.value) # type: ignore[arg-type]
assert cfg.capture_message_content is member
# Each entry is (kwargs, reason); constructing with the kwargs must raise.
_INVALID_CONSTRUCTION_TABLE = [
# genai_semconv_stability_opt_in only accepts the two literals.
(
{'genai_semconv_stability_opt_in': 'gen_ai_latest_experimental'},
'opt_in',
),
({'genai_semconv_stability_opt_in': 'STABLE'}, 'opt_in_case'),
({'genai_semconv_stability_opt_in': ''}, 'opt_in_empty'),
# capture_message_content must be a valid ContentCapturingMode (member or
# its value string); strings outside that set are rejected.
({'capture_message_content': 'not_a_capture_mode'}, 'capture_invalid'),
({'capture_message_content': 'event_only'}, 'capture_wrong_case'),
# extra='forbid' rejects unknown fields.
({'typo_field': 'experimental'}, 'extra_field'),
]
@pytest.mark.parametrize(
'kwargs,_reason',
_INVALID_CONSTRUCTION_TABLE,
ids=[reason for _, reason in _INVALID_CONSTRUCTION_TABLE],
)
def test_telemetry_config_construction_rejects_invalid_values(
kwargs: dict,
_reason: str,
):
"""Invalid field values / unknown fields raise ValidationError on construct."""
with pytest.raises(ValidationError):
TelemetryConfig(**kwargs) # type: ignore[arg-type]
def test_telemetry_config_round_trips_through_json():
"""``RunConfig.telemetry`` round-trips through JSON."""
cfg = RunConfig(
telemetry=TelemetryConfig(
genai_semconv_stability_opt_in='experimental',
capture_message_content=ContentCapturingMode.SPAN_AND_EVENT,
)
)
js = cfg.model_dump_json()
assert 'experimental' in js
assert 'SPAN_AND_EVENT' in js
reloaded = RunConfig.model_validate_json(js)
assert reloaded.telemetry == cfg.telemetry
assert isinstance(reloaded.telemetry, TelemetryConfig)
assert isinstance(
reloaded.telemetry.capture_message_content, ContentCapturingMode
)
# ---------------------------------------------------------------------------
# Env-string parsing edge cases for the capture-mode env var.
#
# The precedence ladder (admin lock > cfg > env > default) for all four
# decision functions is verified end-to-end by the functional ``Runner``
# tests below; these unit tests pin only the env-string parsing quirks that
# the functional tests cannot exercise directly.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('invalid', ['yes', 'on', 'not_a_capture_mode'])
def test_capture_mode_env_invalid_values_treated_as_disabled(
monkeypatch: pytest.MonkeyPatch,
invalid: str,
):
"""Env values outside the OTel four-state set fall back to ''.
Legacy ``'true'`` / ``'1'`` are the only exception; see
``test_capture_mode_env_legacy_*``.
"""
monkeypatch.setenv(_ENV_CAPTURE, invalid)
assert TelemetryConfig().content_capturing_mode_value == ''
@pytest.mark.parametrize('legacy', ['true', 'TRUE', 'True', '1'])
def test_capture_mode_env_legacy_values_coerced_to_event_only(
monkeypatch: pytest.MonkeyPatch,
legacy: str,
):
"""Legacy ``'true'`` / ``'1'`` coerce silently to ``EVENT_ONLY``.
Previously the env path was bool-ish; the canonical value space is now
the OTel four-state enum (matching
``opentelemetry.util.genai.utils.get_content_capturing_mode``).
Coercion preserves observable behavior for existing deployments.
"""
monkeypatch.setenv(_ENV_CAPTURE, legacy)
assert TelemetryConfig().content_capturing_mode_value == 'EVENT_ONLY', (
f"legacy env value {legacy!r} should coerce to 'EVENT_ONLY' for"
' back-compat'
)
@pytest.mark.parametrize('legacy', ['true', '1'])
def test_capture_mode_env_legacy_coercion_is_silent(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
legacy: str,
):
"""Legacy coercion is silent (hot path; no per-span log records)."""
monkeypatch.setenv(_ENV_CAPTURE, legacy)
with caplog.at_level(
'WARNING', logger='google.adk.telemetry._experimental_semconv'
):
assert TelemetryConfig().content_capturing_mode_value == 'EVENT_ONLY'
assert not caplog.records, (
'legacy-value coercion must be silent; got log records:'
f' {[(r.levelname, r.message) for r in caplog.records]}'
)
# ---------------------------------------------------------------------------
# TelemetryConfig resolution properties: the single source of truth.
#
# The decision functions are now thin wrappers over these properties, so the
# precedence ladder (admin lock > per-request field > env var > default) and
# the env-string coercion are pinned here once, directly on the model. The
# functional Runner tests above exercise the same ladder end-to-end; these are
# the fast, exhaustive unit-level guards.
# ---------------------------------------------------------------------------
def test_resolution_properties_read_env_lazily_at_access_time(
monkeypatch: pytest.MonkeyPatch,
):
"""Properties re-read os.environ on each access (not frozen at construction).
This is the whole reason resolution lives in properties rather than a
``default_factory``: a caller that mutates the environment after building the
(frozen) config still observes the new value.
"""
_set_env(monkeypatch)
cfg = TelemetryConfig() # all fields unset => pure env-fallback.
assert cfg.should_use_experimental_genai_semconv is False
monkeypatch.setenv(_ENV_EXPERIMENTAL, 'gen_ai_latest_experimental')
assert cfg.should_use_experimental_genai_semconv is True
monkeypatch.delenv(_ENV_EXPERIMENTAL, raising=False)
assert cfg.should_use_experimental_genai_semconv is False
# (opt_in field, env value, expected) for should_use_experimental_genai_semconv.
# Covers field-wins, env-fallback, and the 'stable' field beating an opted-in
# env var.
_EXPERIMENTAL_RESOLUTION_TABLE = [
# Field set => field wins over env.
('experimental', None, True),
('experimental', 'gen_ai_latest_experimental', True),
('stable', 'gen_ai_latest_experimental', False),
('stable', None, False),
# Field unset => env fallback (token must appear in the CSV list).
(None, 'gen_ai_latest_experimental', True),
(None, 'gen_ai_latest_experimental,http_latest', True),
(None, 'http_latest', False),
(None, None, False),
]
@pytest.mark.parametrize(
'opt_in,env_value,expected', _EXPERIMENTAL_RESOLUTION_TABLE
)
def test_should_use_experimental_genai_semconv_resolution(
monkeypatch: pytest.MonkeyPatch,
opt_in: Optional[str],
env_value: Optional[str],
expected: bool,
):
"""Per-request opt_in wins; otherwise the env CSV opt-in token decides."""
_set_env(monkeypatch, **{_ENV_EXPERIMENTAL: env_value})
cfg = TelemetryConfig(genai_semconv_stability_opt_in=opt_in)
assert cfg.should_use_experimental_genai_semconv is expected
# (capture field, env value, expected mode value string). Exercises field-wins,
# env fallback, the legacy 'true'/'1' coercion, and invalid-env => NO_CONTENT.
_CAPTURE_RESOLUTION_TABLE = [
# Field set => field wins over env (NO_CONTENT maps to '').
(ContentCapturingMode.NO_CONTENT, 'SPAN_AND_EVENT', ''),
(ContentCapturingMode.EVENT_ONLY, None, 'EVENT_ONLY'),
(ContentCapturingMode.SPAN_ONLY, 'EVENT_ONLY', 'SPAN_ONLY'),
(ContentCapturingMode.SPAN_AND_EVENT, None, 'SPAN_AND_EVENT'),
# Field unset => env fallback over the OTel four-state set.
(None, 'EVENT_ONLY', 'EVENT_ONLY'),
(None, 'SPAN_AND_EVENT', 'SPAN_AND_EVENT'),
(None, 'NO_CONTENT', ''),
# Field unset => legacy back-compat coercion of 'true'/'1' to EVENT_ONLY.
(None, 'true', 'EVENT_ONLY'),
(None, '1', 'EVENT_ONLY'),
# Field unset => invalid / absent env value => NO_CONTENT ('').
(None, 'bogus', ''),
(None, None, ''),
]
@pytest.mark.parametrize(
'capture,env_value,expected', _CAPTURE_RESOLUTION_TABLE
)
def test_content_capturing_mode_value_resolution(
monkeypatch: pytest.MonkeyPatch,
capture: Optional[ContentCapturingMode],
env_value: Optional[str],
expected: str,
):
"""Per-request capture field wins; else env fallback w/ legacy coercion."""
_set_env(monkeypatch, **{_ENV_CAPTURE: env_value})
cfg = TelemetryConfig(capture_message_content=capture)
assert cfg.content_capturing_mode_value == expected
# (resolved mode, expect_logs, expect_experimental_spans). Pins the OTel-spec
# routing of a resolved mode onto the LogRecord vs span side.
_CONTENT_ROUTING_TABLE = [
(ContentCapturingMode.NO_CONTENT, False, False),
(ContentCapturingMode.EVENT_ONLY, True, False),
(ContentCapturingMode.SPAN_ONLY, False, True),
(ContentCapturingMode.SPAN_AND_EVENT, True, True),
]
@pytest.mark.parametrize(
'mode,expect_logs,expect_spans', _CONTENT_ROUTING_TABLE
)
def test_content_routing_logs_vs_experimental_spans(
monkeypatch: pytest.MonkeyPatch,
mode: ContentCapturingMode,
expect_logs: bool,
expect_spans: bool,
):
"""EVENT_ONLY routes to logs, SPAN_ONLY to spans, SPAN_AND_EVENT to both."""
_set_env(monkeypatch)
cfg = TelemetryConfig(capture_message_content=mode)
assert cfg.should_add_content_to_logs is expect_logs
assert cfg.should_add_content_to_experimental_spans is expect_spans
# (capture field, ADK span env value, expected). The legacy ADK span knob has
# its OWN env fallback (ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS, default-on),
# distinct from the OTel content env var.
_LEGACY_SPAN_RESOLUTION_TABLE = [
# Field set => OTel-spec routing (only span-bearing modes opt in).
(ContentCapturingMode.SPAN_ONLY, 'false', True),
(ContentCapturingMode.SPAN_AND_EVENT, 'false', True),
(ContentCapturingMode.EVENT_ONLY, 'true', False),
(ContentCapturingMode.NO_CONTENT, 'true', False),
# Field unset => ADK span env var, which DEFAULTS TO ON.
(None, None, True),
(None, 'true', True),
(None, '1', True),
(None, 'false', False),
(None, '0', False),
]
@pytest.mark.parametrize(
'capture,env_value,expected', _LEGACY_SPAN_RESOLUTION_TABLE
)
def test_should_add_content_to_legacy_spans_resolution(
monkeypatch: pytest.MonkeyPatch,
capture: Optional[ContentCapturingMode],
env_value: Optional[str],
expected: bool,
):
"""Legacy ADK span knob: field uses OTel routing, else its own default-on env."""
_set_env(monkeypatch, **{_ENV_ADK_SPAN_CAPTURE: env_value})
cfg = TelemetryConfig(capture_message_content=capture)
assert cfg.should_add_content_to_legacy_spans is expected
def test_admin_lock_disables_all_resolution_properties(
monkeypatch: pytest.MonkeyPatch,
):
"""Admin lock makes every resolution property ignore the per-request fields.
With the lock on and all env vars unset, an opted-in config resolves to the
(empty) env defaults across all properties. The legacy span knob still
defaults to on (its env default), matching pre-CL behavior.
"""
_set_env(monkeypatch, **{_ENV_ADMIN_LOCK: '1'})
cfg = TelemetryConfig(
genai_semconv_stability_opt_in='experimental',
capture_message_content=ContentCapturingMode.SPAN_AND_EVENT,
)
assert cfg.should_use_experimental_genai_semconv is False
assert cfg.content_capturing_mode_value == ''
assert cfg.should_add_content_to_logs is False
assert cfg.should_add_content_to_experimental_spans is False
# Legacy span knob falls back to its env var, which defaults to on.
assert cfg.should_add_content_to_legacy_spans is True
def test_admin_lock_falls_back_to_env_not_per_request_field(
monkeypatch: pytest.MonkeyPatch,
):
"""With the lock on, env wins over the ignored per-request field.
Complements ``test_admin_lock_disables_all_resolution_properties``, which runs
with env unset and so only proves the lock falls back to the env *default*.
Here the per-request fields are set to *suppressing* values while the env vars
are set to *capturing* values, proving each property reads the env (the
operator's setting) rather than forcing the value off.
"""
_set_env(
monkeypatch,
**{
_ENV_ADMIN_LOCK: '1',
_ENV_EXPERIMENTAL: 'gen_ai_latest_experimental',
_ENV_CAPTURE: 'EVENT_ONLY',
_ENV_ADK_SPAN_CAPTURE: 'false',
},
)
cfg = TelemetryConfig(
genai_semconv_stability_opt_in='stable',
capture_message_content=ContentCapturingMode.NO_CONTENT,
)
# Env opts in even though the per-request field said 'stable'.
assert cfg.should_use_experimental_genai_semconv is True
# Env capturing mode wins even though the field said NO_CONTENT.
assert cfg.content_capturing_mode_value == 'EVENT_ONLY'
assert cfg.should_add_content_to_logs is True
# Env says EVENT_ONLY (not span-bearing), so experimental spans stay off.
assert cfg.should_add_content_to_experimental_spans is False
# Legacy span env explicitly set to false wins over the ignored field.
assert cfg.should_add_content_to_legacy_spans is False
# ---------------------------------------------------------------------------
# set_operation_details_common_attributes: must honor the per-request config.
#
# log_only_attributes carry PII-ish data (e.g. user_id). The gate must consult
# the per-request TelemetryConfig, not just the process-global env var, or a
# request that opted out via capture_message_content=NO_CONTENT would still leak
# log-only attributes when the host env defaults to a content-capturing mode.
# ---------------------------------------------------------------------------
def _run_set_common_attrs(
telemetry_config: Optional[TelemetryConfig],
) -> dict:
"""Runs set_operation_details_common_attributes and returns the result map."""
out: dict = {}
set_operation_details_common_attributes(
out,
telemetry_config or TelemetryConfig(),
{'gen_ai.operation.name': 'chat'},
log_only_attributes={'gen_ai.user.id': 'user-123'},
)
return out
def test_set_common_attrs_cfg_no_content_overrides_env_capture(
monkeypatch: pytest.MonkeyPatch,
):
"""Per-request NO_CONTENT suppresses PII-ish log-only attrs even if env opts in.
Security regression guard: ``log_only_attributes`` (e.g. ``user_id``) must be
gated on the per-request config, not just the process-global env var, or a
request that opted out via ``capture_message_content=NO_CONTENT`` would leak
log-only attributes when the host env defaults to a content-capturing mode.
The functional ``Runner`` tests do not assert on log-only attribute routing,
so this stays as a dedicated unit guard.
"""
monkeypatch.setenv(_ENV_CAPTURE, 'EVENT_ONLY')
out = _run_set_common_attrs(
TelemetryConfig(capture_message_content=ContentCapturingMode.NO_CONTENT)
)
assert 'gen_ai.user.id' not in out
# Non-log-only attributes are always set.
assert out['gen_ai.operation.name'] == 'chat'
# ---------------------------------------------------------------------------
# trace_inference_result: invocation_context is Optional[InvocationContext].
# TelemetryContext.invocation_context is Optional, so the signature must accept
# None without raising (None-safe via _telemetry_config_from_invocation_context).
# ---------------------------------------------------------------------------
def test_trace_inference_result_accepts_none_invocation_context():
"""Passing invocation_context=None must not raise (env fallback path)."""
# span=None short-circuits inside the function; the point is that a None
# invocation_context does not blow up signature/None handling.
trace_inference_result(None, None, LlmResponse())
# ---------------------------------------------------------------------------
# Admin-lock value parsing: which env-var spellings count as "locked".
#
# The lock's *effect* (ignoring per-request cfg, falling back to env) is
# verified end-to-end by the admin-lock functional ``Runner`` tests below.
# What those cannot vary is the lock env-var *spelling*, so this single
# parametrized test pins the truthy/falsy matrix once, asserting across all
# four decision functions at the same time.
# ---------------------------------------------------------------------------
# (lock_value, locked) -- 'locked' True means per-request cfg is ignored.
_ADMIN_LOCK_VALUE_TABLE = [
# Recognized truthy spellings (case-insensitive '1' / 'true').
('1', True),
('true', True),
('TRUE', True),
('True', True),
# The parser strips surrounding whitespace before comparing.
(' 1 ', True),
# Everything else is treated as unset: explicit off-ish spellings and
# arbitrary unrecognized strings (the lock uses a strict allowlist, not a
# generic truthy parse, so 'yes' does NOT lock).
('', False),
('0', False),
('false', False),
('no', False),
('off', False),
('yes', False),
]
@pytest.mark.parametrize('lock_value,locked', _ADMIN_LOCK_VALUE_TABLE)
def test_admin_lock_value_parsing(
monkeypatch: pytest.MonkeyPatch,
lock_value: str,
locked: bool,
):
"""Only stripped '1'/'true' (case-insensitive) lock; all else is unset.
When locked, a per-request cfg opting in to experimental + EVENT_ONLY is
ignored and the (empty) env fallback wins; when unlocked, the cfg wins.
Asserts across all four decision functions to pin the shared parsing.
"""
_set_env(monkeypatch, **{_ENV_ADMIN_LOCK: lock_value})
cfg = TelemetryConfig(
genai_semconv_stability_opt_in='experimental',
capture_message_content=ContentCapturingMode.EVENT_ONLY,
)
assert cfg.should_use_experimental_genai_semconv is (not locked)
assert cfg.should_add_content_to_logs is (not locked)
assert bool(cfg.content_capturing_mode_value) is (not locked)
# SPAN-bearing knob: EVENT_ONLY does not enable spans, so when unlocked the
# cfg disables span capture; when locked the env default (on) wins.
assert cfg.should_add_content_to_legacy_spans is locked
def _make_test_runner(
agent_name: str = 'telemetry_test_agent',
) -> InMemoryRunner:
"""Builds an InMemoryRunner with a deterministic MockModel.
Each invocation drives one ``generate_content`` call returning a short
text response, which is enough to exercise the ``use_inference_span`` +
``trace_inference_result`` code path that consumes ``RunConfig.telemetry``.
"""
mock_model = MockModel.create(
responses=[Part.from_text(text='ok')],
)
agent = Agent(name=agent_name, model=mock_model)
return InMemoryRunner(root_agent=agent)
@pytest.fixture
def span_exporter(
monkeypatch: pytest.MonkeyPatch,
) -> InMemorySpanExporter:
"""Captures every span emitted by ``tracing.tracer`` during a test.
Mirrors the fixture in ``telemetry/test_functional.py`` so that any
future change to how the tracer is wired up is picked up here too.
"""
tracer_provider = TracerProvider()
exporter = InMemorySpanExporter()
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
real_tracer = tracer_provider.get_tracer(__name__)
monkeypatch.setattr(
tracing.tracer,
'start_as_current_span',
real_tracer.start_as_current_span,
)
return exporter
# LogRecord event names that distinguish the experimental-semconv path from
# the stable-semconv path. These are the actual emission contract the CL
# delivers, so asserting on their presence (rather than on
# implementation-detail span attributes) gives a robust signal: either the
# experimental code path ran (its LogRecord appears) or the stable code path
# ran (its three LogRecords appear). The two sets are mutually exclusive at
# the call site -- see ``use_inference_span`` (tracing.py) +
# ``maybe_log_completion_details`` (_experimental_semconv.py).
_EXPERIMENTAL_LOG_EVENT = 'gen_ai.client.inference.operation.details'
_STABLE_LOG_EVENTS = frozenset({
'gen_ai.system.message',
'gen_ai.user.message',
'gen_ai.choice',
})
@pytest.fixture
def log_collector(monkeypatch: pytest.MonkeyPatch) -> list:
"""Captures every LogRecord emitted by ``tracing.otel_logger`` during a test.
Patches the module-global rather than wiring an OTel ``LoggerProvider``,
matching how ``test_spans.py`` drives ``otel_logger`` assertions. The
returned list is mutated in place by the patched ``emit``, so assertions
can run after the Runner finishes without copying.
Returns:
A list of the OTel ``LogRecord``s emitted during the test.
"""
collected: list = []
real_emit = tracing.otel_logger.emit
def _collecting_emit(record, *args, **kwargs):
collected.append(record)
return real_emit(record, *args, **kwargs)
monkeypatch.setattr(tracing.otel_logger, 'emit', _collecting_emit)
return collected
def _experimental_logs(records: list) -> list:
"""Filters captured LogRecords down to the experimental-semconv emission."""
return [r for r in records if r.event_name == _EXPERIMENTAL_LOG_EVENT]
def _stable_logs(records: list) -> list:
"""Filters captured LogRecords down to stable-semconv emissions."""
return [r for r in records if r.event_name in _STABLE_LOG_EVENTS]
@pytest.mark.asyncio
async def test_runner_invocation_with_experimental_telemetry_emits_experimental_log(
monkeypatch: pytest.MonkeyPatch,
span_exporter: InMemorySpanExporter,
log_collector: list,
):
"""Per-request experimental opt-in drives the experimental emission end-to-end.
Runs one invocation with
``genai_semconv_stability_opt_in='experimental'`` and asserts a
``gen_ai.client.inference.operation.details`` LogRecord was emitted.
``maybe_log_completion_details`` emits it only when
``is_experimental_semconv(telemetry_config)`` is True, so its presence
proves the config was threaded through ``Runner.run_async`` ->
``InvocationContext.run_config.telemetry`` -> ``use_inference_span`` ->
``is_experimental_semconv``.
``span_exporter`` is wired in because ``maybe_log_completion_details``
bails out when its ``span`` argument is ``None``.
"""
monkeypatch.delenv(_ENV_EXPERIMENTAL, raising=False)
runner = _make_test_runner()
session = await runner.runner.session_service.create_session(
app_name=runner.app_name, user_id='test_user'
)
async for _ in runner.runner.run_async(
user_id=session.user_id,
session_id=session.id,
new_message=UserContent('hi'),
run_config=RunConfig(
telemetry=TelemetryConfig(
genai_semconv_stability_opt_in='experimental'
)
),
):
pass
exp = _experimental_logs(log_collector)
stable = _stable_logs(log_collector)
assert exp, (
f'expected at least one {_EXPERIMENTAL_LOG_EVENT!r} LogRecord but got'
' none; emitted event_names='
f'{sorted({r.event_name for r in log_collector})}. This means'
' RunConfig.telemetry was NOT threaded into use_inference_span and the'
' helper fell back to the stable-semconv code path.'
)
assert not stable, (
'experimental path should NOT emit the stable-semconv per-message'
f' LogRecords ({sorted(_STABLE_LOG_EVENTS)}); got'
f' {sorted({r.event_name for r in stable})}.'
)
@pytest.mark.asyncio
async def test_runner_invocation_without_telemetry_falls_back_to_env(
monkeypatch: pytest.MonkeyPatch,
span_exporter: InMemorySpanExporter,
log_collector: list,
):
"""No per-request config => env var path remains in effect (back-compat).
Prior behavior must be preserved when ``RunConfig.telemetry`` is unset:
the env var ``OTEL_SEMCONV_STABILITY_OPT_IN`` should still flip the
experimental path on, so the experimental LogRecord still appears.
"""
monkeypatch.setenv(_ENV_EXPERIMENTAL, 'gen_ai_latest_experimental')
runner = _make_test_runner()
session = await runner.runner.session_service.create_session(
app_name=runner.app_name, user_id='test_user'
)
async for _ in runner.runner.run_async(
user_id=session.user_id,
session_id=session.id,
new_message=UserContent('hi'),
# No telemetry= field => RunConfig.telemetry defaults to None.
run_config=RunConfig(),
):
pass
exp = _experimental_logs(log_collector)
assert exp, (
'env-var path no longer enables experimental semconv when'
' RunConfig.telemetry is None -- regression on backward compat.'
f' emitted event_names={sorted({r.event_name for r in log_collector})}'
)
@pytest.mark.asyncio
async def test_runner_invocation_with_experimental_false_takes_stable_path(
monkeypatch: pytest.MonkeyPatch,
span_exporter: InMemorySpanExporter,
log_collector: list,
):
"""Per-request 'stable' suppresses the experimental emission even when env opts in.
An invocation that has not opted into the experimental schema keeps
getting stable-semconv emissions even while a process-global env var
(set by the host for other invocations) requests the experimental
schema.
"""
monkeypatch.setenv(_ENV_EXPERIMENTAL, 'gen_ai_latest_experimental')
runner = _make_test_runner()
session = await runner.runner.session_service.create_session(
app_name=runner.app_name, user_id='test_user'
)
async for _ in runner.runner.run_async(
user_id=session.user_id,
session_id=session.id,
new_message=UserContent('hi'),
run_config=RunConfig(
telemetry=TelemetryConfig(genai_semconv_stability_opt_in='stable')
),
):
pass
exp = _experimental_logs(log_collector)
stable = _stable_logs(log_collector)
assert not exp, (
"genai_semconv_stability_opt_in='stable' should suppress the"
f' {_EXPERIMENTAL_LOG_EVENT!r} LogRecord regardless of the env var,'
f' but got {len(exp)} such records. The env-var fallback beat the'
' explicit per-request override.'
)
assert stable, (
'stable-semconv path should emit per-message LogRecords'
f' ({sorted(_STABLE_LOG_EVENTS)}); got none. emitted event_names='
f'{sorted({r.event_name for r in log_collector})}'
)
@pytest.mark.asyncio
async def test_concurrent_runner_invocations_do_not_leak_telemetry(
monkeypatch: pytest.MonkeyPatch,
span_exporter: InMemorySpanExporter,
log_collector: list,
):
"""Two concurrent invocations with opposite TelemetryConfigs each see their own.
Runs two ``InMemoryRunner`` instances (one per simulated tenant)
concurrently via ``asyncio.gather`` and asserts each tenant's emitted
LogRecords match its own config, so ``telemetry_config`` did not leak
across the two requests.
Tenants are disambiguated by ``gen_ai.agent.name`` on the experimental
record's attributes; stable per-message records carry no agent identity,
so the opted-out tenant is identified by the absence of its experimental
record plus the presence of stable per-message records overall.
Isolation holds by construction (no shared mutable state); pinned so a
future global or contextvar inside the decision functions trips this
test.
"""
monkeypatch.delenv(_ENV_EXPERIMENTAL, raising=False)
tenant_on = _make_test_runner('tenant_on')
tenant_off = _make_test_runner('tenant_off')
async def _run(
runner: InMemoryRunner,
telemetry_config: Optional[TelemetryConfig],
) -> None:
session = await runner.runner.session_service.create_session(
app_name=runner.app_name, user_id='test_user'
)
async for _ in runner.runner.run_async(
user_id=session.user_id,
session_id=session.id,
new_message=UserContent('hi'),
run_config=RunConfig(telemetry=telemetry_config),
):
pass
await asyncio.gather(
_run(
tenant_on,
TelemetryConfig(genai_semconv_stability_opt_in='experimental'),
),
_run(
tenant_off, TelemetryConfig(genai_semconv_stability_opt_in='stable')
),
)
# Bucket experimental records by agent.
exp_by_agent: dict[str, int] = {}
for r in _experimental_logs(log_collector):
agent = (r.attributes or {}).get('gen_ai.agent.name')
if agent is None:
continue
exp_by_agent[agent] = exp_by_agent.get(agent, 0) + 1
assert exp_by_agent.get('tenant_on', 0) >= 1, (
"tenant_on (genai_semconv_stability_opt_in='experimental') did NOT emit"
f' any {_EXPERIMENTAL_LOG_EVENT!r} LogRecord -- telemetry_config was'
f' dropped or leaked into tenant_off. exp_by_agent={exp_by_agent}'
)
assert exp_by_agent.get('tenant_off', 0) == 0, (
"tenant_off (genai_semconv_stability_opt_in='stable') unexpectedly"
f" emitted an {_EXPERIMENTAL_LOG_EVENT!r} LogRecord -- tenant_on's"
f' telemetry_config bled into tenant_off. exp_by_agent={exp_by_agent}'
)
# Stable records don't carry agent identity, so we can only assert
# globally; tenant_off is the only opted-out invocation in this test.
assert _stable_logs(log_collector), (
"tenant_off (genai_semconv_stability_opt_in='stable') should have"
' produced stable-semconv per-message LogRecords'
f' ({sorted(_STABLE_LOG_EVENTS)}) but none were emitted. emitted'
f' event_names={sorted({r.event_name for r in log_collector})}'
)
@pytest.mark.asyncio
async def test_runner_invocation_with_admin_lock_ignores_per_request_telemetry(
monkeypatch: pytest.MonkeyPatch,
span_exporter: InMemorySpanExporter,
log_collector: list,
):
"""Admin lock makes the runner ignore RunConfig.telemetry end-to-end.
With the lock set, ``OTEL_SEMCONV_STABILITY_OPT_IN`` unset, and a
per-request ``genai_semconv_stability_opt_in='experimental'``, the
experimental LogRecord must not appear (the lock wins) and the stable
per-message LogRecords must appear (the env-var fallback takes effect).
Mirror image of
``test_runner_invocation_with_experimental_telemetry_emits_experimental_log``
with the admin lock added.
Args:
monkeypatch: pytest fixture for setting / unsetting env vars.
span_exporter: wires a real span SDK so ``maybe_log_completion_details``
does not bail out on ``span=None``.
log_collector: collects every LogRecord emitted by ``tracing.otel_logger``
during the test.
"""
del span_exporter # Wired up by fixture; not directly asserted on.
monkeypatch.setenv(_ENV_ADMIN_LOCK, '1')
monkeypatch.delenv(_ENV_EXPERIMENTAL, raising=False)
runner = _make_test_runner()
session = await runner.runner.session_service.create_session(
app_name=runner.app_name, user_id='test_user'
)
async for _ in runner.runner.run_async(
user_id=session.user_id,
session_id=session.id,
new_message=UserContent('hi'),
run_config=RunConfig(
telemetry=TelemetryConfig(
genai_semconv_stability_opt_in='experimental'
)
),
):
pass
exp = _experimental_logs(log_collector)
stable = _stable_logs(log_collector)
assert not exp, (
'admin lock was active but the per-request'
" genai_semconv_stability_opt_in='experimental'"
f' still produced {len(exp)} {_EXPERIMENTAL_LOG_EVENT!r} LogRecord(s);'
' some call site bypassed the lock guard in is_experimental_semconv'
' and reached the per-request field directly. Emitted event_names='
f'{sorted({r.event_name for r in log_collector})}'
)
assert stable, (
'admin lock active + env var unset should fall back to the'
' stable-semconv per-message LogRecords'
f' ({sorted(_STABLE_LOG_EVENTS)}); got none. Emitted event_names='
f'{sorted({r.event_name for r in log_collector})}'
)
_GCP_LLM_REQUEST_ATTR = 'gcp.vertex.agent.llm_request'
def _llm_request_span_attrs(
span_exporter: InMemorySpanExporter,
) -> list[str | int | float | bool | None]:
"""Collects the legacy ADK llm_request span attribute across all spans."""
return [
span.attributes.get(_GCP_LLM_REQUEST_ATTR)
for span in span_exporter.get_finished_spans()
if span.attributes is not None
and _GCP_LLM_REQUEST_ATTR in span.attributes
]
@pytest.mark.asyncio
async def test_runner_invocation_with_capture_false_elides_legacy_span_attrs(
monkeypatch: pytest.MonkeyPatch,
span_exporter: InMemorySpanExporter,
log_collector: list,
):
"""Per-request NO_CONTENT suppresses legacy ADK span attributes.
With ``ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=true`` (env default-on) and a
per-request ``capture_message_content=NO_CONTENT``, the
``gcp.vertex.agent.llm_request`` span attribute set by ``trace_call_llm``
must be elided to ``'{}'``, so the per-request override wins over the env
var through ``base_llm_flow.py`` -> ``trace_call_llm`` ->
``_should_add_request_response_to_spans``.
Args:
monkeypatch: pytest fixture for setting / unsetting env vars.
span_exporter: wires a real span SDK so trace_call_llm emits the attributes
asserted on here.
log_collector: collects OTel LogRecords; kept to match the wiring of the
LogRecord-side tests, not asserted on here.
"""
del log_collector # Kept to match otel_logger wiring; not asserted on.
monkeypatch.setenv(_ENV_ADK_SPAN_CAPTURE, 'true')
runner = _make_test_runner()
session = await runner.runner.session_service.create_session(
app_name=runner.app_name, user_id='test_user'
)
async for _ in runner.runner.run_async(
user_id=session.user_id,
session_id=session.id,
new_message=UserContent('hi'),
run_config=RunConfig(
telemetry=TelemetryConfig(
capture_message_content=ContentCapturingMode.NO_CONTENT
)
),
):
pass
llm_request_attrs = _llm_request_span_attrs(span_exporter)
assert llm_request_attrs, (
f'no spans had the {_GCP_LLM_REQUEST_ATTR!r} attribute set; '
'trace_call_llm may not have run. spans='
f'{[s.name for s in span_exporter.get_finished_spans()]}'
)
assert all(v == '{}' for v in llm_request_attrs), (
f"expected all {_GCP_LLM_REQUEST_ATTR!r} attrs to be elided to '{{}}'"
' when capture_message_content=ContentCapturingMode.NO_CONTENT;'
' per-request override did not reach trace_call_llm. got'
f' attrs={llm_request_attrs}'
)
@pytest.mark.asyncio
async def test_runner_invocation_with_admin_lock_ignores_span_capture_override(
monkeypatch: pytest.MonkeyPatch,
span_exporter: InMemorySpanExporter,
log_collector: list,
):
"""Admin lock applies end-to-end to legacy ADK span capture too.
With the lock on, ``ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=false``, and a
per-request ``capture_message_content=SPAN_AND_EVENT``, the
``gcp.vertex.agent.llm_request`` span attribute is elided to ``'{}'``
(lock + env both say no, override ignored). A bypassed lock guard at the
``trace_call_llm`` site would re-enable capture and fail the assertion.
``SPAN_AND_EVENT`` is used rather than ``EVENT_ONLY`` because only the
span-bearing modes (``SPAN_ONLY`` / ``SPAN_AND_EVENT``) flip the legacy
ADK span knob to True; ``EVENT_ONLY`` would pass trivially even if the
lock were not honored.
Args:
monkeypatch: pytest fixture for setting / unsetting env vars.
span_exporter: wires a real span SDK.
log_collector: not asserted on here; see sibling test.
"""
del log_collector
monkeypatch.setenv(_ENV_ADMIN_LOCK, '1')
monkeypatch.setenv(_ENV_ADK_SPAN_CAPTURE, 'false')
runner = _make_test_runner()
session = await runner.runner.session_service.create_session(
app_name=runner.app_name, user_id='test_user'
)
async for _ in runner.runner.run_async(
user_id=session.user_id,
session_id=session.id,
new_message=UserContent('hi'),
run_config=RunConfig(
telemetry=TelemetryConfig(
capture_message_content=ContentCapturingMode.SPAN_AND_EVENT
)
),
):
pass
llm_request_attrs = _llm_request_span_attrs(span_exporter)
assert (
llm_request_attrs
), f'no spans had the {_GCP_LLM_REQUEST_ATTR!r} attribute set'
assert all(v == '{}' for v in llm_request_attrs), (
'admin lock + env=false should suppress the legacy ADK span'
' content attribute regardless of per-request capture=True; some'
' call site bypassed the lock guard. attrs={llm_request_attrs}'
)