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
1046 lines
40 KiB
Python
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}'
|
|
)
|