Skip to content

Commit abcaa08

Browse files
google-genai-botcopybara-github
authored andcommitted
feat(telemetry): support per-request OpenTelemetry configuration
Adds a TelemetryConfig that callers can attach to RunConfig.telemetry to set OpenTelemetry knobs per request instead of via process-wide OTEL_* env vars. Each field falls back to its env var when unset, so the default preserves existing behavior. PiperOrigin-RevId: 929715433
1 parent 89f6e59 commit abcaa08

10 files changed

Lines changed: 1490 additions & 76 deletions

File tree

src/google/adk/agents/run_config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from pydantic import model_validator
3030

3131
from ..sessions.base_session_service import GetSessionConfig
32+
from ..telemetry.context import TelemetryConfig
3233

3334
logger = logging.getLogger('google_adk.' + __name__)
3435

@@ -327,6 +328,19 @@ class RunConfig(BaseModel):
327328
custom_metadata: Optional[dict[str, Any]] = None
328329
"""Custom metadata for the current invocation."""
329330

331+
telemetry: TelemetryConfig | None = None
332+
"""Per-request OpenTelemetry configuration.
333+
334+
Overrides the process-global telemetry env vars for the duration of this
335+
invocation. Each ``None`` field on the
336+
:class:`~google.adk.telemetry.TelemetryConfig` falls back to its
337+
corresponding env var. Lets multi-tenant hosts toggle telemetry knobs per
338+
request without leaking configuration across concurrent invocations.
339+
340+
.. warning::
341+
Experimental; API may change.
342+
"""
343+
330344
get_session_config: Optional[GetSessionConfig] = None
331345
"""Configuration for controlling which events are fetched when loading
332346
a session.

src/google/adk/flows/llm_flows/base_llm_flow.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@
2828
from websockets.exceptions import ConnectionClosed
2929
from websockets.exceptions import ConnectionClosedOK
3030

31-
from . import _output_schema_processor
32-
from . import functions
3331
from ...agents.base_agent import BaseAgent
3432
from ...agents.callback_context import CallbackContext
3533
from ...agents.invocation_context import InvocationContext
@@ -52,6 +50,8 @@
5250
from ...tools.tool_context import ToolContext
5351
from ...utils import model_name_utils
5452
from ...utils.context_utils import Aclosing
53+
from . import _output_schema_processor
54+
from . import functions
5555
from .audio_cache_manager import AudioCacheManager
5656
from .functions import build_auth_request_event
5757

@@ -385,7 +385,7 @@ async def _run_on_model_error_callbacks(
385385
) as tel_ctx:
386386
async with Aclosing(response_generator) as agen:
387387
async for llm_response in agen:
388-
tel_ctx.record_llm_response(llm_response)
388+
tel_ctx.record_llm_response(invocation_context, llm_response)
389389
yield llm_response
390390
except Exception as model_error:
391391
callback_context = CallbackContext(
@@ -434,8 +434,8 @@ async def _process_agent_tools(
434434
names to ``BaseTool`` instances ready for function call dispatch.
435435
436436
Args:
437-
invocation_context: The invocation context (``agent`` is read
438-
from ``invocation_context.agent``).
437+
invocation_context: The invocation context (``agent`` is read from
438+
``invocation_context.agent``).
439439
llm_request: The LLM request to populate with tool declarations.
440440
"""
441441
agent = invocation_context.agent

src/google/adk/flows/llm_flows/functions.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ async def handle_function_call_list_async(
470470
trace_merged_tool_calls(
471471
response_event_id=merged_event.id,
472472
function_response_event=merged_event,
473+
invocation_context=invocation_context,
473474
)
474475
return merged_event
475476

@@ -642,7 +643,7 @@ async def _run_with_trace():
642643
return function_response_event
643644

644645
async with _instrumentation.record_tool_execution(
645-
tool, agent, function_args
646+
tool, agent, function_args, invocation_context=invocation_context
646647
) as tel_ctx:
647648
tel_ctx.function_response_event = await _run_with_trace()
648649
tel_ctx.error_type = detected_error_type
@@ -712,6 +713,7 @@ async def handle_function_calls_live(
712713
trace_merged_tool_calls(
713714
response_event_id=merged_event.id,
714715
function_response_event=merged_event,
716+
invocation_context=invocation_context,
715717
)
716718
return merged_event
717719

@@ -889,7 +891,7 @@ async def _run_with_trace():
889891
return function_response_event
890892

891893
async with _instrumentation.record_tool_execution(
892-
tool, agent, function_args
894+
tool, agent, function_args, invocation_context=invocation_context
893895
) as tel_ctx:
894896
tel_ctx.function_response_event = await _run_with_trace()
895897
tel_ctx.error_type = detected_error_type

src/google/adk/telemetry/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from .context import ContentCapturingMode
16+
from .context import TelemetryConfig
1517
from .tracing import trace_call_llm
1618
from .tracing import trace_merged_tool_calls
1719
from .tracing import trace_send_data
1820
from .tracing import trace_tool_call
1921
from .tracing import tracer
2022

2123
__all__ = [
24+
'ContentCapturingMode',
25+
'TelemetryConfig',
2226
'trace_call_llm',
2327
'trace_merged_tool_calls',
2428
'trace_send_data',

src/google/adk/telemetry/_experimental_semconv.py

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@
1919

2020
from collections.abc import Mapping
2121
from collections.abc import MutableMapping
22-
import contextvars
2322
import json
24-
import os
2523
import sys
2624
from typing import Any
2725
from typing import Literal
@@ -54,15 +52,17 @@
5452
GEN_AI_OUTPUT_MESSAGES = 'gen_ai.output.messages'
5553
GEN_AI_SYSTEM_INSTRUCTIONS = 'gen_ai.system_instructions'
5654

55+
from .context import TelemetryConfig
56+
5757
# Use the import symbol once the minimum OpenTelemetry SDK version is updated to 1.39.0
5858
# from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_TOOL_DEFINITIONS
5959
GEN_AI_TOOL_DEFINITIONS = 'gen_ai.tool.definitions'
6060

61-
OTEL_SEMCONV_STABILITY_OPT_IN = 'OTEL_SEMCONV_STABILITY_OPT_IN'
61+
# Use the import symbol once the minimum OpenTelemetry SDK version is updated to 1.40.0
62+
# from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS
63+
GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS = 'gen_ai.usage.cache_read.input_tokens'
6264

63-
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = (
64-
'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'
65-
)
65+
GEN_AI_USAGE_REASONING_OUTPUT_TOKENS = 'gen_ai.usage.reasoning.output_tokens'
6666

6767
FUNCTION_TOOL_DEFINITION_TYPE = 'function'
6868

@@ -133,7 +133,8 @@ def _safe_json_serialize_no_whitespaces(obj) -> str:
133133
obj: The object to serialize.
134134
135135
Returns:
136-
The JSON-serialized object string or <non-serializable> if the object cannot be serialized.
136+
The JSON-serialized object string or <non-serializable> if the object cannot
137+
be serialized.
137138
"""
138139

139140
try:
@@ -148,18 +149,43 @@ def _safe_json_serialize_no_whitespaces(obj) -> str:
148149
return '<not serializable>'
149150

150151

151-
def is_experimental_semconv() -> bool:
152-
opt_ins = os.getenv(OTEL_SEMCONV_STABILITY_OPT_IN)
153-
if not opt_ins:
154-
return False
155-
opt_ins_list = [s.strip() for s in opt_ins.split(',')]
156-
return 'gen_ai_latest_experimental' in opt_ins_list
152+
def is_experimental_semconv(
153+
telemetry_config: TelemetryConfig | None = None,
154+
) -> bool:
155+
"""Returns whether to emit experimental Generative AI semconv attributes.
156+
157+
Thin wrapper over
158+
:attr:`TelemetryConfig.should_use_experimental_genai_semconv`, which owns the
159+
precedence ladder (admin lock > per-request field > env var > default).
160+
161+
Args:
162+
telemetry_config: The per-request config, or ``None`` for the env-only path
163+
(modeled as an empty :class:`TelemetryConfig`).
164+
165+
Returns:
166+
Whether the experimental GenAI semconv attributes should be emitted.
167+
"""
168+
cfg = telemetry_config if telemetry_config is not None else TelemetryConfig()
169+
return cfg.should_use_experimental_genai_semconv
157170

158171

159-
def get_content_capturing_mode() -> str:
160-
return os.getenv(
161-
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, ''
162-
).upper()
172+
def get_content_capturing_mode(
173+
telemetry_config: TelemetryConfig | None = None,
174+
) -> str:
175+
"""Returns the experimental GenAI semconv content-capturing mode string.
176+
177+
Thin wrapper over :attr:`TelemetryConfig.content_capturing_mode_value`, which
178+
owns the precedence ladder and the legacy env-string coercion.
179+
180+
Args:
181+
telemetry_config: The per-request config, or ``None`` for the env-only path
182+
(modeled as an empty :class:`TelemetryConfig`).
183+
184+
Returns:
185+
One of ``''`` / ``'EVENT_ONLY'`` / ``'SPAN_ONLY'`` / ``'SPAN_AND_EVENT'``.
186+
"""
187+
cfg = telemetry_config if telemetry_config is not None else TelemetryConfig()
188+
return cfg.content_capturing_mode_value
163189

164190

165191
def _model_dump_to_tool_definition(tool: Any) -> dict[str, Any]:
@@ -439,12 +465,11 @@ def set_operation_details_common_attributes(
439465
operation_details_common_attributes: MutableMapping[str, AttributeValue],
440466
attributes: Mapping[str, AttributeValue],
441467
log_only_attributes: Mapping[str, AttributeValue] | None = None,
468+
telemetry_config: TelemetryConfig | None = None,
442469
) -> None:
443470
operation_details_common_attributes.update(attributes)
444-
if log_only_attributes and get_content_capturing_mode() in (
445-
'EVENT_ONLY',
446-
'SPAN_AND_EVENT',
447-
):
471+
cfg = telemetry_config if telemetry_config is not None else TelemetryConfig()
472+
if log_only_attributes and cfg.should_add_content_to_logs:
448473
operation_details_common_attributes.update(log_only_attributes)
449474

450475

@@ -499,18 +524,19 @@ def maybe_log_completion_details(
499524
otel_logger: Logger,
500525
operation_details_attributes: Mapping[str, AttributeValue],
501526
operation_details_common_attributes: Mapping[str, AttributeValue],
527+
telemetry_config: TelemetryConfig | None = None,
502528
):
503-
"""Logs completion details based on the experimental semantic convention capturing mode."""
529+
"""Logs completion details based on the experimental semconv capturing mode."""
504530
if span is None:
505531
return
506532

507-
if not is_experimental_semconv():
533+
cfg = telemetry_config if telemetry_config is not None else TelemetryConfig()
534+
if not cfg.should_use_experimental_genai_semconv:
508535
return
509536

510-
capturing_mode = get_content_capturing_mode()
511537
final_attributes = operation_details_common_attributes
512538

513-
if capturing_mode in ['EVENT_ONLY', 'SPAN_AND_EVENT']:
539+
if cfg.should_add_content_to_logs:
514540
final_attributes = final_attributes | operation_details_attributes
515541
else:
516542
final_attributes = (
@@ -525,7 +551,7 @@ def maybe_log_completion_details(
525551
)
526552
)
527553

528-
if capturing_mode in ['SPAN_ONLY', 'SPAN_AND_EVENT']:
554+
if cfg.should_add_content_to_experimental_spans:
529555
for key, value in operation_details_attributes.items():
530556
span.set_attribute(key, _safe_json_serialize_no_whitespaces(value))
531557
else:

src/google/adk/telemetry/_instrumentation.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@
2626
from opentelemetry import trace
2727
import opentelemetry.context as context_api
2828

29+
from ..events import event as event_lib
2930
from . import _metrics
3031
from . import tracing
31-
from ..events import event as event_lib
3232

3333
if TYPE_CHECKING:
3434
from ..agents.base_agent import BaseAgent
@@ -84,9 +84,11 @@ class TelemetryContext:
8484
def llm_responses(self) -> list[LlmResponse]:
8585
return self._llm_responses
8686

87-
def record_llm_response(self, response: LlmResponse) -> None:
87+
def record_llm_response(
88+
self, invocation_context: InvocationContext, response: LlmResponse
89+
) -> None:
8890
self._llm_responses.append(response)
89-
tracing.trace_inference_result(self.span, response)
91+
tracing.trace_inference_result(invocation_context, self.span, response)
9092

9193

9294
def _record_agent_metrics(
@@ -143,6 +145,7 @@ async def record_tool_execution(
143145
tool: BaseTool,
144146
agent: BaseAgent,
145147
function_args: dict[str, Any],
148+
invocation_context: InvocationContext | None = None,
146149
) -> AsyncIterator[TelemetryContext]:
147150
"""Unified context manager for consolidated tool execution telemetry."""
148151
start_time = time.monotonic()
@@ -167,6 +170,7 @@ async def record_tool_execution(
167170
args=function_args,
168171
function_response_event=response_event,
169172
error=caught_error,
173+
invocation_context=invocation_context,
170174
error_type=tel_ctx.error_type,
171175
)
172176
finally:

0 commit comments

Comments
 (0)