feat(telemetry): add feature gate for experimental telemetry

PiperOrigin-RevId: 958330566
This commit is contained in:
Google Team Member
2026-08-03 06:05:06 -07:00
committed by Copybara-Service
parent f4e7233469
commit 75fb2544e1
2 changed files with 154 additions and 12 deletions
+28 -1
View File
@@ -33,6 +33,7 @@ from typing import Optional
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import StrictBool
ADK_TELEMETRY_IGNORE_RUN_CONFIG = 'ADK_TELEMETRY_IGNORE_RUN_CONFIG'
OTEL_SEMCONV_STABILITY_OPT_IN = 'OTEL_SEMCONV_STABILITY_OPT_IN'
@@ -41,6 +42,7 @@ OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = (
)
# Legacy ADK span-content knob; unlike the OTel env var above, it defaults on.
ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS = 'ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS'
ADK_EXPERIMENTAL_TELEMETRY = 'ADK_EXPERIMENTAL_TELEMETRY'
# Token in OTEL_SEMCONV_STABILITY_OPT_IN that selects experimental GenAI semconv.
_GENAI_EXPERIMENTAL_OPT_IN = 'gen_ai_latest_experimental'
@@ -83,7 +85,8 @@ class TelemetryConfig(BaseModel):
Attached to an invocation via ``RunConfig.telemetry``. Any field left as
``None`` falls back to its corresponding env var (an ``OTEL_*`` var, plus the
default-on ``ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS`` for legacy spans).
default-on ``ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS`` for legacy spans, and
default-off ``ADK_EXPERIMENTAL_TELEMETRY`` for experimental telemetry).
``frozen=True`` lets the same config be shared safely across concurrent
invocations; the resolution properties read env lazily, so later
``os.environ`` changes are still picked up.
@@ -105,6 +108,8 @@ class TelemetryConfig(BaseModel):
``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT``. Pass a
:class:`ContentCapturingMode` member; the env-var path accepts the
matching uppercase string.
adk_experimental_telemetry_opt_in: Override for
``ADK_EXPERIMENTAL_TELEMETRY``.
"""
model_config = ConfigDict(frozen=True, extra='forbid')
@@ -113,6 +118,7 @@ class TelemetryConfig(BaseModel):
Literal['stable', 'experimental']
] = None
capture_message_content: Optional[ContentCapturingMode] = None
adk_experimental_telemetry_opt_in: Optional[StrictBool] = None
@property
def _ignore_per_request(self) -> bool:
@@ -211,3 +217,24 @@ class TelemetryConfig(BaseModel):
os.getenv(ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS, 'true').strip().lower()
)
return env_value not in _FALSY_ENV_VALUES
@property
def should_emit_experimental_telemetry(self) -> bool:
"""Whether to emit experimental telemetry.
Experimental telemetry includes all spans, logs, metrics, and attributes
whose meaning or format is subject to change, or is not yet available via
standard OTel knobs. As of writing this, it is only used for in-progress
skill related telemetry changes.
Precedence: admin lock > ``adk_experimental_telemetry_opt_in`` >
``ADK_EXPERIMENTAL_TELEMETRY`` env var > ``False``.
"""
if (
not self._ignore_per_request
and self.adk_experimental_telemetry_opt_in is not None
):
return self.adk_experimental_telemetry_opt_in
env_value = os.getenv(ADK_EXPERIMENTAL_TELEMETRY, 'false').strip().lower()
return env_value in _TRUTHY_ENV_VALUES
@@ -17,6 +17,8 @@
from __future__ import annotations
import asyncio
import itertools
import re
from typing import Optional
from google.adk.agents.llm_agent import Agent
@@ -43,12 +45,14 @@ _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
_ENV_ADK_EXPERIMENTAL_TELEMETRY = 'ADK_EXPERIMENTAL_TELEMETRY'
_ALL_TELEMETRY_ENV_VARS = (
_ENV_EXPERIMENTAL,
_ENV_CAPTURE,
_ENV_ADK_SPAN_CAPTURE,
_ENV_ADMIN_LOCK,
_ENV_ADK_EXPERIMENTAL_TELEMETRY,
)
@@ -74,7 +78,7 @@ def test_telemetry_config_is_frozen():
# ---------------------------------------------------------------------------
# Construction truth table for ``TelemetryConfig`` itself (no env vars, no
# decision functions). Covers the cartesian product of the two fields'
# decision functions). Covers the cartesian product of the three fields'
# accepted/rejected values: every valid combination must construct and
# preserve its field values; every invalid value must raise ValidationError.
# ---------------------------------------------------------------------------
@@ -88,27 +92,35 @@ _VALID_CAPTURE_VALUES = (
ContentCapturingMode.SPAN_ONLY,
ContentCapturingMode.SPAN_AND_EVENT,
)
_VALID_ADK_EXPERIMENTAL_TELEMETRY_VALUES = (None, True, False)
# 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
]
_VALID_CONSTRUCTION_TABLE = list(
itertools.product(
_VALID_OPT_IN_VALUES,
_VALID_CAPTURE_VALUES,
_VALID_ADK_EXPERIMENTAL_TELEMETRY_VALUES,
)
)
@pytest.mark.parametrize('opt_in,capture', _VALID_CONSTRUCTION_TABLE)
@pytest.mark.parametrize(
'opt_in,capture,adk_experimental_telemetry', _VALID_CONSTRUCTION_TABLE
)
def test_telemetry_config_construction_accepts_valid_combinations(
opt_in: Optional[str],
capture: Optional[ContentCapturingMode],
adk_experimental_telemetry: Optional[bool],
):
"""Every valid (opt_in, capture) pair constructs and round-trips its fields."""
"""Every valid (opt_in, capture, adk_experimental_telemetry) triple constructs and round-trips its fields."""
cfg = TelemetryConfig(
genai_semconv_stability_opt_in=opt_in,
capture_message_content=capture,
adk_experimental_telemetry_opt_in=adk_experimental_telemetry,
)
assert cfg.genai_semconv_stability_opt_in == opt_in
assert cfg.capture_message_content == capture
assert cfg.adk_experimental_telemetry_opt_in == adk_experimental_telemetry
@pytest.mark.parametrize('member', list(ContentCapturingMode))
@@ -140,6 +152,31 @@ _INVALID_CONSTRUCTION_TABLE = [
({'capture_message_content': 'event_only'}, 'capture_wrong_case'),
# extra='forbid' rejects unknown fields.
({'typo_field': 'experimental'}, 'extra_field'),
# adk_experimental_telemetry_opt_in must be a bool.
(
{'adk_experimental_telemetry_opt_in': 'true'},
'bool_str_true',
),
(
{'adk_experimental_telemetry_opt_in': 'false'},
'bool_str_false',
),
(
{'adk_experimental_telemetry_opt_in': 1},
'bool_1',
),
(
{'adk_experimental_telemetry_opt_in': 0},
'bool_0',
),
(
{'adk_experimental_telemetry_opt_in': 'yes'},
'bool_yes',
),
(
{'adk_experimental_telemetry_opt_in': 'no'},
'bool_no',
),
]
@@ -163,11 +200,10 @@ def test_telemetry_config_round_trips_through_json():
telemetry=TelemetryConfig(
genai_semconv_stability_opt_in='experimental',
capture_message_content=ContentCapturingMode.SPAN_AND_EVENT,
adk_experimental_telemetry_opt_in=True,
)
)
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)
@@ -393,6 +429,78 @@ def test_should_add_content_to_legacy_spans_resolution(
assert cfg.should_add_content_to_legacy_spans is expected
_EXPERIMENTAL_TELEMETRY_RESOLUTION_TABLE = [
pytest.param('true', True, 'true', True, id='lock_ignores_opt_in_0'),
pytest.param('true', True, 'false', False, id='lock_ignores_opt_in_1'),
pytest.param('true', False, 'true', True, id='lock_ignores_opt_in_2'),
pytest.param('true', False, 'false', False, id='lock_ignores_opt_in_3'),
pytest.param('true', None, '0', False, id='number_value_accepted_0'),
pytest.param('true', None, '1', True, id='number_value_accepted_1'),
pytest.param('true', None, 'yes', False, id='bad_env_value_ignored_0'),
pytest.param('true', None, 'no', False, id='bad_env_value_ignored_1'),
pytest.param('false', True, 'true', True, id='no_lock_opt_in_precedence_0'),
pytest.param(
'false', True, 'false', True, id='no_lock_opt_in_precedence_1'
),
pytest.param(
'false', False, 'true', False, id='no_lock_opt_in_precedence_2'
),
pytest.param(
'false', False, 'false', False, id='no_lock_opt_in_precedence_3'
),
pytest.param(
'false',
None,
'true',
True,
id='no_lock_no_opt_in_env_precedence_0',
),
pytest.param(
'false',
None,
'false',
False,
id='no_lock_no_opt_in_env_precedence_1',
),
pytest.param(
'true',
None,
None,
False,
id='default_value_false_lock_doesnt_matter_0',
),
pytest.param(
'false',
None,
None,
False,
id='default_value_false_lock_doesnt_matter_1',
),
]
@pytest.mark.parametrize(
'lock,opt_in,env_value,expected', _EXPERIMENTAL_TELEMETRY_RESOLUTION_TABLE
)
def test_should_record_experimental_telemetry_resolution(
monkeypatch: pytest.MonkeyPatch,
env_value: str | None,
lock: str,
opt_in: bool | None,
expected: bool,
):
"""Admin lock > per-request field > env var > default."""
_set_env(
monkeypatch,
**{
_ENV_ADK_EXPERIMENTAL_TELEMETRY: env_value,
_ENV_ADMIN_LOCK: lock,
},
)
cfg = TelemetryConfig(adk_experimental_telemetry_opt_in=opt_in)
assert cfg.should_emit_experimental_telemetry is expected
def test_admin_lock_disables_all_resolution_properties(
monkeypatch: pytest.MonkeyPatch,
):
@@ -406,6 +514,7 @@ def test_admin_lock_disables_all_resolution_properties(
cfg = TelemetryConfig(
genai_semconv_stability_opt_in='experimental',
capture_message_content=ContentCapturingMode.SPAN_AND_EVENT,
adk_experimental_telemetry_opt_in=True,
)
assert cfg.should_use_experimental_genai_semconv is False
assert cfg.content_capturing_mode_value == ''
@@ -413,6 +522,7 @@ def test_admin_lock_disables_all_resolution_properties(
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
assert cfg.should_emit_experimental_telemetry is False
def test_admin_lock_falls_back_to_env_not_per_request_field(
@@ -433,11 +543,13 @@ def test_admin_lock_falls_back_to_env_not_per_request_field(
_ENV_EXPERIMENTAL: 'gen_ai_latest_experimental',
_ENV_CAPTURE: 'EVENT_ONLY',
_ENV_ADK_SPAN_CAPTURE: 'false',
_ENV_ADK_EXPERIMENTAL_TELEMETRY: 'true',
},
)
cfg = TelemetryConfig(
genai_semconv_stability_opt_in='stable',
capture_message_content=ContentCapturingMode.NO_CONTENT,
adk_experimental_telemetry_opt_in=False,
)
# Env opts in even though the per-request field said 'stable'.
assert cfg.should_use_experimental_genai_semconv is True
@@ -448,6 +560,7 @@ def test_admin_lock_falls_back_to_env_not_per_request_field(
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
assert cfg.should_emit_experimental_telemetry is True
# ---------------------------------------------------------------------------
@@ -550,12 +663,13 @@ def test_admin_lock_value_parsing(
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.
Asserts across all five 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,
adk_experimental_telemetry_opt_in=True,
)
assert cfg.should_use_experimental_genai_semconv is (not locked)
assert cfg.should_add_content_to_logs is (not locked)
@@ -563,6 +677,7 @@ def test_admin_lock_value_parsing(
# 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
assert cfg.should_emit_experimental_telemetry is (not locked)
def _make_test_runner(