Skip to content

Commit 9fbb44e

Browse files
committed
[WIP] Prototype of semconv open-telemetry#2179 (event format)
1 parent 575ab31 commit 9fbb44e

File tree

7 files changed

+723
-9
lines changed

7 files changed

+723
-9
lines changed

instrumentation-genai/opentelemetry-instrumentation-google-genai/pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ classifiers = [
3737
"Programming Language :: Python :: 3.12"
3838
]
3939
dependencies = [
40+
"fsspec>=2025.5.1",
4041
"opentelemetry-api >=1.31.1, <2",
4142
"opentelemetry-instrumentation >=0.52b1, <2",
42-
"opentelemetry-semantic-conventions >=0.52b1, <2"
43+
"opentelemetry-semantic-conventions >=0.52b1, <2",
4344
]
4445

4546
[project.optional-dependencies]
@@ -77,3 +78,8 @@ stubPath = "types"
7778
reportMissingImports = "error"
7879
reportMissingTypeStubs = false
7980
pythonVersion = "3.9"
81+
82+
[dependency-groups]
83+
dev = [
84+
"pillow>=11.2.1",
85+
]

instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py

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

15+
from __future__ import annotations
16+
1517
import copy
1618
import functools
1719
import json
1820
import logging
1921
import os
2022
import time
2123
from typing import Any, AsyncIterator, Awaitable, Iterator, Optional, Union
24+
from uuid import uuid4
2225

2326
from google.genai.models import AsyncModels, Models
27+
from google.genai.models import t as transformers
2428
from google.genai.types import (
2529
BlockedReason,
2630
Candidate,
@@ -45,6 +49,11 @@
4549
from .custom_semconv import GCP_GENAI_OPERATION_CONFIG
4650
from .dict_util import flatten_dict
4751
from .flags import is_content_recording_enabled
52+
from .message import (
53+
to_input_messages,
54+
to_output_message,
55+
to_system_instruction,
56+
)
4857
from .otel_wrapper import OTelWrapper
4958
from .tool_call_wrapper import wrapped as wrapped_tool
5059

@@ -143,6 +152,17 @@ def _to_dict(value: object):
143152
return json.loads(json.dumps(value))
144153

145154

155+
def _config_to_system_instruction(
156+
config: GenerateContentConfigOrDict | None,
157+
) -> ContentUnion | None:
158+
if not config:
159+
return None
160+
161+
if isinstance(config, dict):
162+
return GenerateContentConfig.model_validate(config).system_instruction
163+
return config.system_instruction
164+
165+
146166
def _add_request_options_to_span(
147167
span, config: Optional[GenerateContentConfigOrDict], allow_list: AllowList
148168
):
@@ -242,6 +262,7 @@ def __init__(
242262
):
243263
self._start_time = time.time_ns()
244264
self._otel_wrapper = otel_wrapper
265+
self._models_object = models_object
245266
self._genai_system = _determine_genai_system(models_object)
246267
self._genai_request_model = model
247268
self._finish_reasons_set = set()
@@ -290,18 +311,25 @@ def process_request(
290311
_add_request_options_to_span(
291312
span, config, self._generate_content_config_key_allowlist
292313
)
293-
self._maybe_log_system_instruction(config=config)
294-
self._maybe_log_user_prompt(contents)
295314

296-
def process_response(self, response: GenerateContentResponse):
315+
def process_completion(
316+
self,
317+
*,
318+
config: Optional[GenerateContentConfigOrDict],
319+
request: Union[ContentListUnion, ContentListUnionDict],
320+
response: GenerateContentResponse,
321+
):
297322
# TODO: Determine if there are other response properties that
298323
# need to be reflected back into the span attributes.
299324
#
300325
# See also: TODOS.md.
326+
self._maybe_log_completion_details(
327+
config=config, request=request, response=response
328+
)
301329
self._update_finish_reasons(response)
302330
self._maybe_update_token_counts(response)
303331
self._maybe_update_error_type(response)
304-
self._maybe_log_response(response)
332+
# self._maybe_log_response(response)
305333
self._response_index += 1
306334

307335
def process_error(self, e: Exception):
@@ -373,6 +401,45 @@ def _maybe_update_error_type(self, response: GenerateContentResponse):
373401
block_reason = response.prompt_feedback.block_reason.name.upper()
374402
self._error_type = f"BLOCKED_{block_reason}"
375403

404+
def _maybe_log_completion_details(
405+
self,
406+
*,
407+
config: Optional[GenerateContentConfigOrDict],
408+
request: Union[ContentListUnion, ContentListUnionDict],
409+
response: GenerateContentResponse,
410+
) -> None:
411+
attributes = {
412+
gen_ai_attributes.GEN_AI_SYSTEM: self._genai_system,
413+
}
414+
415+
system_instruction = None
416+
if system_content := _config_to_system_instruction(config):
417+
system_instruction = to_system_instruction(
418+
content=transformers.t_contents(system_content)[0]
419+
)
420+
input_messages = to_input_messages(
421+
contents=transformers.t_contents(request)
422+
)
423+
output_message = to_output_message(
424+
candidates=response.candidates or []
425+
)
426+
427+
self._otel_wrapper.log_completion_details(
428+
system_instructions=system_instruction,
429+
input_messages=input_messages,
430+
output_messages=output_message,
431+
attributes=attributes,
432+
)
433+
434+
# Forward looking remote storage refs
435+
self._otel_wrapper.log_completion_details_refs(
436+
system_instructions=system_instruction,
437+
input_messages=input_messages,
438+
output_messages=output_message,
439+
attributes=attributes,
440+
response_id=response.response_id or str(uuid4()),
441+
)
442+
376443
def _maybe_log_system_instruction(
377444
self, config: Optional[GenerateContentConfigOrDict] = None
378445
):
@@ -596,7 +663,9 @@ def instrumented_generate_content(
596663
config=helper.wrapped_config(config),
597664
**kwargs,
598665
)
599-
helper.process_response(response)
666+
helper.process_completion(
667+
config=config, request=contents, response=response
668+
)
600669
return response
601670
except Exception as error:
602671
helper.process_error(error)
@@ -641,7 +710,9 @@ def instrumented_generate_content_stream(
641710
config=helper.wrapped_config(config),
642711
**kwargs,
643712
):
644-
helper.process_response(response)
713+
helper.process_completion(
714+
config=config, request=contents, response=response
715+
)
645716
yield response
646717
except Exception as error:
647718
helper.process_error(error)
@@ -686,7 +757,10 @@ async def instrumented_generate_content(
686757
config=helper.wrapped_config(config),
687758
**kwargs,
688759
)
689-
helper.process_response(response)
760+
helper.process_completion(
761+
config=config, request=contents, response=response
762+
)
763+
690764
return response
691765
except Exception as error:
692766
helper.process_error(error)
@@ -744,7 +818,9 @@ async def _response_async_generator_wrapper():
744818
with trace.use_span(span, end_on_exit=True):
745819
try:
746820
async for response in response_async_generator:
747-
helper.process_response(response)
821+
helper.process_completion(
822+
config=config, request=contents, response=response
823+
)
748824
yield response
749825
except Exception as error:
750826
helper.process_error(error)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# Copyright The OpenTelemetry Authors
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+
from __future__ import annotations
16+
17+
import logging
18+
19+
from google.genai import types as genai_types
20+
21+
from .message_models import (
22+
BlobPart,
23+
ChatMessage,
24+
FileDataPart,
25+
FinishReason,
26+
InputMessages,
27+
MessagePart,
28+
OutputMessage,
29+
OutputMessages,
30+
Role,
31+
TextPart,
32+
ToolCallPart,
33+
ToolCallResponsePart,
34+
)
35+
36+
_logger = logging.getLogger(__name__)
37+
38+
39+
def to_input_messages(
40+
*,
41+
contents: list[genai_types.Content],
42+
) -> InputMessages:
43+
return InputMessages([_to_chat_message(content) for content in contents])
44+
45+
46+
def to_output_message(
47+
*,
48+
candidates: list[genai_types.Candidate],
49+
) -> OutputMessages:
50+
def content_to_output_message(
51+
candidate: genai_types.Candidate,
52+
) -> OutputMessage | None:
53+
if not candidate.content:
54+
return None
55+
56+
message = _to_chat_message(candidate.content)
57+
return OutputMessage(
58+
finish_reason=_to_finish_reason(candidate.finish_reason),
59+
role=message.role,
60+
parts=message.parts,
61+
)
62+
63+
messages = (
64+
content_to_output_message(candidate) for candidate in candidates
65+
)
66+
return OutputMessages(
67+
[message for message in messages if message is not None]
68+
)
69+
70+
71+
# TODO: re-using ChatMessage for now but it is defined as any in
72+
# https://github.com/open-telemetry/semantic-conventions/pull/2179. I prefer ChatMessage.
73+
def to_system_instruction(
74+
*,
75+
content: genai_types.Content,
76+
) -> ChatMessage | None:
77+
return _to_chat_message(content)
78+
79+
80+
def _to_chat_message(
81+
content: genai_types.Content,
82+
) -> ChatMessage:
83+
parts = (_to_part(part) for part in (content.parts or []))
84+
return ChatMessage(
85+
role=_to_role(content.role),
86+
# filter Nones
87+
parts=[part for part in parts if part is not None],
88+
)
89+
90+
91+
def _to_part(part: genai_types.Part) -> MessagePart | None:
92+
if (text := part.text) is not None:
93+
return TextPart(content=text)
94+
95+
if data := part.inline_data:
96+
return BlobPart(mime_type=data.mime_type or "", data=data.data or b"")
97+
98+
if data := part.file_data:
99+
return FileDataPart(
100+
mime_type=data.mime_type or "", file_uri=data.file_uri or ""
101+
)
102+
103+
if call := part.function_call:
104+
return ToolCallPart(
105+
id=call.id or "", name=call.name or "", arguments=call.args
106+
)
107+
108+
if response := part.function_response:
109+
return ToolCallResponsePart(
110+
id=response.id or "",
111+
result=response.response,
112+
)
113+
114+
_logger.info("Unknown part dropped from telemetry %s", part)
115+
return None
116+
117+
118+
def _to_role(role: str | None) -> Role | str:
119+
if role == "user":
120+
return Role.USER
121+
elif role == "model":
122+
return Role.ASSISTANT
123+
return ""
124+
125+
126+
def _to_finish_reason(
127+
finish_reason: genai_types.FinishReason | None,
128+
) -> FinishReason | str:
129+
if finish_reason is None:
130+
return ""
131+
if (
132+
finish_reason is genai_types.FinishReason.FINISH_REASON_UNSPECIFIED
133+
or finish_reason is genai_types.FinishReason.OTHER
134+
):
135+
return FinishReason.ERROR
136+
if finish_reason is genai_types.FinishReason.STOP:
137+
return FinishReason.STOP
138+
if finish_reason is genai_types.FinishReason.MAX_TOKENS:
139+
return FinishReason.LENGTH
140+
141+
# If there is no 1:1 mapping to an OTel preferred enum value, use the exact vertex reason
142+
return finish_reason.name

0 commit comments

Comments
 (0)