Skip to content

Commit 23c0826

Browse files
RKestcopybara-github
authored andcommitted
refactor(otel): Add pure functions for constructing stable and experimental semconv logs
Co-authored-by: Max Ind <maxind@google.com> PiperOrigin-RevId: 933035924
1 parent 60c55ad commit 23c0826

8 files changed

Lines changed: 655 additions & 505 deletions

File tree

src/google/adk/telemetry/_experimental_semconv.py

Lines changed: 295 additions & 243 deletions
Large diffs are not rendered by default.

src/google/adk/telemetry/_instrumentation.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import logging
2020
import sys
2121
import time
22-
from typing import Any
2322
from typing import AsyncIterator
2423
from typing import TYPE_CHECKING
2524

@@ -94,8 +93,8 @@ def record_llm_response(
9493
def _record_agent_metrics(
9594
agent_name: str,
9695
elapsed_s: float,
97-
user_content: Any,
98-
events: Any,
96+
user_content: object,
97+
events: object,
9998
caught_error: Exception | None,
10099
) -> None:
101100
try:
@@ -143,7 +142,7 @@ async def record_agent_invocation(
143142
async def record_tool_execution(
144143
tool: BaseTool,
145144
agent: BaseAgent,
146-
function_args: dict[str, Any],
145+
function_args: dict[str, object],
147146
invocation_context: InvocationContext | None = None,
148147
) -> AsyncIterator[TelemetryContext]:
149148
"""Unified context manager for consolidated tool execution telemetry."""
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Shared serialization helpers used by telemetry modules."""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
21+
from google.genai import types
22+
from opentelemetry.util.types import AnyValue
23+
from pydantic import BaseModel
24+
25+
26+
def safe_json_serialize(obj: object) -> str:
27+
"""Convert any Python object to a JSON-serializable type or string.
28+
29+
Args:
30+
obj: The object to serialize.
31+
32+
Returns:
33+
The JSON-serialized object string or `<not serializable>` if the object
34+
cannot be serialized.
35+
"""
36+
try:
37+
return json.dumps(
38+
obj, ensure_ascii=False, default=lambda o: "<not serializable>"
39+
)
40+
except (TypeError, ValueError, OverflowError):
41+
return "<not serializable>"
42+
43+
44+
def serialize_content(content: types.ContentUnion | None) -> AnyValue:
45+
"""Serialize a `types.ContentUnion` value into an OTel-friendly form.
46+
47+
- `None` is preserved.
48+
- Pydantic models are dumped via `model_dump()`.
49+
- Strings are returned as-is.
50+
- Lists are recursively serialized.
51+
- Anything else falls back to `safe_json_serialize`.
52+
"""
53+
if content is None:
54+
return None
55+
if isinstance(content, BaseModel):
56+
return content.model_dump()
57+
if isinstance(content, str):
58+
return content
59+
if isinstance(content, list):
60+
return [serialize_content(part) for part in content]
61+
return safe_json_serialize(content)
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Helpers for building log bodies that follow the stable OTel GenAI semconv.
16+
17+
This module centralizes the construction of `gen_ai.system.message`,
18+
`gen_ai.user.message`, and `gen_ai.choice` log bodies so that both the
19+
tracing layer (which emits the logs) and the ADK Web UI exporter (which
20+
rebuilds the bodies after elision) share the same shape.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from collections.abc import Mapping
26+
from typing import TYPE_CHECKING
27+
28+
from opentelemetry.util.types import AnyValue
29+
30+
from ._serialization import serialize_content
31+
from .context import TelemetryConfig
32+
33+
if TYPE_CHECKING:
34+
from google.genai import types
35+
36+
from ..models.llm_request import LlmRequest
37+
from ..models.llm_response import LlmResponse
38+
39+
# Stable OTel GenAI semantic-convention event names.
40+
GEN_AI_SYSTEM_MESSAGE_EVENT = "gen_ai.system.message"
41+
GEN_AI_USER_MESSAGE_EVENT = "gen_ai.user.message"
42+
GEN_AI_CHOICE_EVENT = "gen_ai.choice"
43+
44+
# Standard OTEL env variable that controls whether prompt/response content is
45+
# included in log bodies. When unset/false, content is replaced with <elided>.
46+
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = (
47+
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
48+
)
49+
50+
USER_CONTENT_ELIDED = "<elided>"
51+
52+
53+
def _serialize_content_with_optional_elision(
54+
content: types.ContentUnion | None, *, capture_content: bool
55+
) -> AnyValue:
56+
if not capture_content:
57+
return USER_CONTENT_ELIDED
58+
if content is None:
59+
return None
60+
return serialize_content(content)
61+
62+
63+
def system_message_body(
64+
llm_request: LlmRequest,
65+
telemetry_config: TelemetryConfig,
66+
*,
67+
do_not_elide: bool = False,
68+
) -> Mapping[str, AnyValue]:
69+
"""Builds the body for a `gen_ai.system.message` log event.
70+
71+
Args:
72+
llm_request: The LLM request whose system instruction should be logged.
73+
do_not_elide_content: When True, always include the content regardless of
74+
the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` env var. The Web
75+
UI exporter sets this to True because the UI needs the full content.
76+
"""
77+
system_instruction = None
78+
if llm_request.config is not None:
79+
system_instruction = llm_request.config.system_instruction
80+
return {
81+
"content": _serialize_content_with_optional_elision(
82+
system_instruction,
83+
capture_content=do_not_elide
84+
or telemetry_config.should_add_content_to_logs,
85+
)
86+
}
87+
88+
89+
def user_message_body(
90+
content: types.ContentUnion | None,
91+
telemetry_config: TelemetryConfig,
92+
*,
93+
do_not_elide: bool = False,
94+
) -> Mapping[str, AnyValue]:
95+
"""Builds the body for a single `gen_ai.user.message` log event.
96+
97+
Args:
98+
content: The user content for this message. Callers that emit multiple user
99+
messages (e.g. tracing's per-content loop) call this builder once per
100+
content.
101+
do_not_elide_content: When True, always include the content regardless of
102+
the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` env var.
103+
"""
104+
return {
105+
"content": _serialize_content_with_optional_elision(
106+
content,
107+
capture_content=do_not_elide
108+
or telemetry_config.should_add_content_to_logs,
109+
)
110+
}
111+
112+
113+
def choice_body(
114+
llm_response: LlmResponse | None,
115+
telemetry_config: TelemetryConfig,
116+
*,
117+
do_not_elide: bool = False,
118+
) -> Mapping[str, AnyValue]:
119+
"""Builds the body for a `gen_ai.choice` log event.
120+
121+
ADK always returns a single candidate, so `index` is always 0.
122+
`finish_reason` is included only when present on the response.
123+
124+
Args:
125+
llm_response: The LLM response describing the choice.
126+
do_not_elide_content: When True, always include the content regardless of
127+
the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` env var.
128+
"""
129+
if llm_response is None:
130+
return {"content": None, "index": 0}
131+
body: dict[str, AnyValue] = {
132+
"content": _serialize_content_with_optional_elision(
133+
llm_response.content,
134+
capture_content=do_not_elide
135+
or telemetry_config.should_add_content_to_logs,
136+
),
137+
"index": 0, # ADK always returns a single candidate.
138+
}
139+
if llm_response.finish_reason is not None:
140+
finish_reason = llm_response.finish_reason
141+
body["finish_reason"] = (
142+
finish_reason.value
143+
if hasattr(finish_reason, "value")
144+
else str(finish_reason)
145+
)
146+
return body

src/google/adk/telemetry/sqlite_span_exporter.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import logging
2121
import sqlite3
2222
import threading
23-
from typing import Any
2423
from typing import Iterable
2524
from typing import Optional
2625
from typing import Sequence
@@ -104,7 +103,7 @@ def _ensure_schema(self) -> None:
104103
conn.execute(_CREATE_TRACE_INDEX)
105104
conn.commit()
106105

107-
def _serialize_attributes(self, attributes: dict[str, Any]) -> str:
106+
def _serialize_attributes(self, attributes: dict[str, object]) -> str:
108107
try:
109108
return json.dumps(
110109
attributes,
@@ -115,7 +114,9 @@ def _serialize_attributes(self, attributes: dict[str, Any]) -> str:
115114
logger.debug("Failed to serialize span attributes: %r", e)
116115
return "{}"
117116

118-
def _deserialize_attributes(self, attributes_json: Any) -> dict[str, Any]:
117+
def _deserialize_attributes(
118+
self, attributes_json: object
119+
) -> dict[str, object]:
119120
if not attributes_json:
120121
return {}
121122
try:
@@ -129,7 +130,7 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
129130
try:
130131
with self._lock:
131132
conn = self._get_connection()
132-
rows: list[tuple[Any, ...]] = []
133+
rows: list[tuple[object, ...]] = []
133134
for span in spans:
134135
attributes = dict(span.attributes) if span.attributes else {}
135136
session_id = attributes.get(
@@ -168,7 +169,7 @@ def shutdown(self) -> None:
168169
def force_flush(self, timeout_millis: int = 30000) -> bool:
169170
return True
170171

171-
def _query(self, sql: str, params: Iterable[Any]) -> list[sqlite3.Row]:
172+
def _query(self, sql: str, params: Iterable[object]) -> list[sqlite3.Row]:
172173
with self._lock:
173174
conn = self._get_connection()
174175
cur = conn.execute(sql, tuple(params))

0 commit comments

Comments
 (0)