feat(telemetry): add feature gate for experimental telemetry
PiperOrigin-RevId: 958330566
This commit is contained in:
committed by
Copybara-Service
parent
f4e7233469
commit
75fb2544e1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user