Skip to content

Commit 19718e9

Browse files
wikaaaaacopybara-github
authored andcommitted
feat(otel): add experimental semantic convention and emit gen_ai.client.inference.operation.details event
Co-authored-by: Wiktoria Walczak <wwalczak@google.com> PiperOrigin-RevId: 875709959
1 parent 6f772d2 commit 19718e9

5 files changed

Lines changed: 727 additions & 19 deletions

File tree

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -368,11 +368,17 @@ async def _run_on_model_error_callbacks(
368368

369369
try:
370370
async with Aclosing(response_generator) as agen:
371-
with tracing.use_generate_content_span(
372-
llm_request, invocation_context, model_response_event
373-
) as span:
371+
async with tracing.use_inference_span(
372+
llm_request,
373+
invocation_context,
374+
model_response_event,
375+
) as gc_span:
374376
async for llm_response in agen:
375-
tracing.trace_generate_content_result(span, llm_response)
377+
if gc_span:
378+
tracing.trace_inference_result(
379+
gc_span,
380+
llm_response,
381+
)
376382
yield llm_response
377383
except Exception as model_error:
378384
callback_context = CallbackContext(
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
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+
16+
"""Provides instrumentation for experimental semantic convention https://github.com/open-telemetry/semantic-conventions/blob/v1.39.0/docs/gen-ai/gen-ai-events.md."""
17+
18+
from __future__ import annotations
19+
20+
from collections.abc import Mapping
21+
from collections.abc import MutableMapping
22+
import contextvars
23+
import json
24+
import os
25+
from typing import Any
26+
from typing import Literal
27+
from typing import TypedDict
28+
29+
from google.genai import types
30+
from google.genai.models import t as transformers
31+
from opentelemetry._logs import Logger
32+
from opentelemetry._logs import LogRecord
33+
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_INPUT_MESSAGES
34+
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_OUTPUT_MESSAGES
35+
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_RESPONSE_FINISH_REASONS
36+
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_SYSTEM_INSTRUCTIONS
37+
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_USAGE_INPUT_TOKENS
38+
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_USAGE_OUTPUT_TOKENS
39+
from opentelemetry.trace import Span
40+
from opentelemetry.util.types import AttributeValue
41+
42+
from ..models.llm_request import LlmRequest
43+
from ..models.llm_response import LlmResponse
44+
45+
OTEL_SEMCONV_STABILITY_OPT_IN = 'OTEL_SEMCONV_STABILITY_OPT_IN'
46+
47+
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = (
48+
'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'
49+
)
50+
51+
52+
class Text(TypedDict):
53+
content: str
54+
type: Literal['text']
55+
56+
57+
class Blob(TypedDict):
58+
mime_type: str
59+
data: bytes
60+
type: Literal['blob']
61+
62+
63+
class FileData(TypedDict):
64+
mime_type: str
65+
uri: str
66+
type: Literal['file_data']
67+
68+
69+
class ToolCall(TypedDict):
70+
id: str | None
71+
name: str
72+
arguments: Any
73+
type: Literal['tool_call']
74+
75+
76+
class ToolCallResponse(TypedDict):
77+
id: str | None
78+
response: Any
79+
type: Literal['tool_call_response']
80+
81+
82+
Part = Text | Blob | FileData | ToolCall | ToolCallResponse
83+
84+
85+
class InputMessage(TypedDict):
86+
role: str
87+
parts: list[Part]
88+
89+
90+
class OutputMessage(TypedDict):
91+
role: str
92+
parts: list[Part]
93+
finish_reason: str
94+
95+
96+
def _safe_json_serialize_no_whitespaces(obj) -> str:
97+
"""Convert any Python object to a JSON-serializable type or string.
98+
99+
Args:
100+
obj: The object to serialize.
101+
102+
Returns:
103+
The JSON-serialized object string or <non-serializable> if the object cannot be serialized.
104+
"""
105+
106+
try:
107+
# Try direct JSON serialization first
108+
return json.dumps(
109+
obj,
110+
separators=(',', ':'),
111+
ensure_ascii=False,
112+
default=lambda o: '<not serializable>',
113+
)
114+
except (TypeError, OverflowError):
115+
return '<not serializable>'
116+
117+
118+
def is_experimental_semconv() -> bool:
119+
opt_ins = os.getenv(OTEL_SEMCONV_STABILITY_OPT_IN)
120+
if not opt_ins:
121+
return False
122+
opt_ins_list = [s.strip() for s in opt_ins.split(',')]
123+
return 'gen_ai_latest_experimental' in opt_ins_list
124+
125+
126+
def get_content_capturing_mode() -> str:
127+
return os.getenv(
128+
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, ''
129+
).upper()
130+
131+
132+
def _to_input_message(
133+
content: types.Content,
134+
) -> InputMessage:
135+
parts = (_to_part(part, idx) for idx, part in enumerate(content.parts or []))
136+
return InputMessage(
137+
role=_to_role(content.role),
138+
parts=[part for part in parts if part is not None],
139+
)
140+
141+
142+
def _to_output_message(
143+
llm_response: LlmResponse,
144+
) -> OutputMessage | None:
145+
if not llm_response.content:
146+
return None
147+
148+
message = _to_input_message(llm_response.content)
149+
return OutputMessage(
150+
role=message['role'],
151+
parts=message['parts'],
152+
finish_reason=_to_finish_reason(llm_response.finish_reason),
153+
)
154+
155+
156+
def _to_finish_reason(
157+
finish_reason: types.FinishReason | None,
158+
) -> str:
159+
if finish_reason is None:
160+
return ''
161+
if (
162+
# Mapping unspecified and other to error,
163+
# as JSON schema for finish_reason does not support them.
164+
finish_reason is types.FinishReason.FINISH_REASON_UNSPECIFIED
165+
or finish_reason is types.FinishReason.OTHER
166+
):
167+
return 'error'
168+
if finish_reason is types.FinishReason.STOP:
169+
return 'stop'
170+
if finish_reason is types.FinishReason.MAX_TOKENS:
171+
return 'length'
172+
173+
return finish_reason.name.lower()
174+
175+
176+
def _to_part(part: types.Part, idx: int) -> Part | None:
177+
def tool_call_id_fallback(name: str | None) -> str:
178+
if name:
179+
return f'{name}_{idx}'
180+
return f'{idx}'
181+
182+
if part is None:
183+
return None
184+
185+
if (text := part.text) is not None:
186+
return Text(content=text, type='text')
187+
188+
if data := part.inline_data:
189+
return Blob(
190+
mime_type=data.mime_type or '', data=data.data or b'', type='blob'
191+
)
192+
193+
if data := part.file_data:
194+
return FileData(
195+
mime_type=data.mime_type or '',
196+
uri=data.file_uri or '',
197+
type='file_data',
198+
)
199+
200+
if call := part.function_call:
201+
return ToolCall(
202+
id=call.id or tool_call_id_fallback(call.name),
203+
name=call.name or '',
204+
arguments=call.args,
205+
type='tool_call',
206+
)
207+
208+
if response := part.function_response:
209+
return ToolCallResponse(
210+
id=response.id or tool_call_id_fallback(response.name),
211+
response=response.response,
212+
type='tool_call_response',
213+
)
214+
215+
return None
216+
217+
218+
def _to_role(role: str | None) -> str:
219+
if role == 'user':
220+
return 'user'
221+
if role == 'model':
222+
return 'assistant'
223+
return ''
224+
225+
226+
def _to_input_messages(contents: list[types.Content]) -> list[InputMessage]:
227+
return [_to_input_message(content) for content in contents]
228+
229+
230+
def _to_system_instructions(
231+
config: types.GenerateContentConfig,
232+
) -> list[Part]:
233+
234+
if not config.system_instruction:
235+
return []
236+
237+
transformed_contents = transformers.t_contents(config.system_instruction)
238+
if not transformed_contents:
239+
return []
240+
241+
sys_instr = transformed_contents[0]
242+
243+
parts = (
244+
_to_part(part, idx) for idx, part in enumerate(sys_instr.parts or [])
245+
)
246+
return [part for part in parts if part is not None]
247+
248+
249+
def set_operation_details_common_attributes(
250+
operation_details_common_attributes: MutableMapping[str, AttributeValue],
251+
attributes: Mapping[str, AttributeValue],
252+
):
253+
operation_details_common_attributes.update(attributes)
254+
255+
256+
async def set_operation_details_attributes_from_request(
257+
operation_details_attributes: MutableMapping[str, AttributeValue],
258+
llm_request: LlmRequest,
259+
):
260+
261+
input_messages = _to_input_messages(
262+
transformers.t_contents(llm_request.contents)
263+
)
264+
265+
system_instructions = _to_system_instructions(llm_request.config)
266+
267+
operation_details_attributes[GEN_AI_INPUT_MESSAGES] = input_messages
268+
operation_details_attributes[GEN_AI_SYSTEM_INSTRUCTIONS] = system_instructions
269+
270+
271+
def set_operation_details_attributes_from_response(
272+
llm_response: LlmResponse,
273+
operation_details_attributes: MutableMapping[str, AttributeValue],
274+
operation_details_common_attributes: MutableMapping[str, AttributeValue],
275+
):
276+
if finish_reason := llm_response.finish_reason:
277+
operation_details_common_attributes[GEN_AI_RESPONSE_FINISH_REASONS] = [
278+
_to_finish_reason(finish_reason)
279+
]
280+
if usage_metadata := llm_response.usage_metadata:
281+
if usage_metadata.prompt_token_count is not None:
282+
operation_details_common_attributes[GEN_AI_USAGE_INPUT_TOKENS] = (
283+
usage_metadata.prompt_token_count
284+
)
285+
if usage_metadata.candidates_token_count is not None:
286+
operation_details_common_attributes[GEN_AI_USAGE_OUTPUT_TOKENS] = (
287+
usage_metadata.candidates_token_count
288+
)
289+
290+
output_message = _to_output_message(llm_response)
291+
if output_message is not None:
292+
operation_details_attributes[GEN_AI_OUTPUT_MESSAGES] = [output_message]
293+
294+
295+
def maybe_log_completion_details(
296+
span: Span | None,
297+
otel_logger: Logger,
298+
operation_details_attributes: Mapping[str, AttributeValue],
299+
operation_details_common_attributes: Mapping[str, AttributeValue],
300+
):
301+
"""Logs completion details based on the experimental semantic convention capturing mode."""
302+
if span is None:
303+
return
304+
305+
if not is_experimental_semconv():
306+
return
307+
308+
capturing_mode = get_content_capturing_mode()
309+
final_attributes = operation_details_common_attributes
310+
311+
if capturing_mode in ['EVENT_ONLY', 'SPAN_AND_EVENT']:
312+
final_attributes = final_attributes | operation_details_attributes
313+
314+
otel_logger.emit(
315+
LogRecord(
316+
event_name='gen_ai.client.inference.operation.details',
317+
attributes=final_attributes,
318+
)
319+
)
320+
321+
if capturing_mode in ['SPAN_ONLY', 'SPAN_AND_EVENT']:
322+
for key, value in operation_details_attributes.items():
323+
span.set_attribute(key, _safe_json_serialize_no_whitespaces(value))

0 commit comments

Comments
 (0)