Skip to content

Commit 194f0b0

Browse files
committed
Improve quality of event logging.
1 parent 1a01b79 commit 194f0b0

File tree

3 files changed

+140
-30
lines changed

3 files changed

+140
-30
lines changed

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

Lines changed: 125 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import functools
16+
import json
1617
import logging
1718
import os
1819
import time
@@ -21,6 +22,10 @@
2122
from google.genai.models import AsyncModels, Models
2223
from google.genai.types import (
2324
BlockedReason,
25+
Candidate,
26+
Content,
27+
ContentUnion,
28+
ContentUnionDict,
2429
ContentListUnion,
2530
ContentListUnionDict,
2631
GenerateContentConfigOrDict,
@@ -40,6 +45,9 @@
4045
_logger = logging.getLogger(__name__)
4146

4247

48+
# Constant used to make the absence of content more understandable.
49+
_CONTENT_ELIDED = "<elided>"
50+
4351
# Enable these after these cases are fully vetted and tested
4452
_INSTRUMENT_STREAMING = False
4553
_INSTRUMENT_ASYNC = False
@@ -182,6 +190,15 @@ def _get_top_p(config: Optional[GenerateContentConfigOrDict]):
182190
}
183191

184192

193+
def _to_dict(value: object):
194+
if isinstance(value, dict):
195+
return value
196+
if hasattr(value, 'model_dump'):
197+
return value.model_dump()
198+
return json.loads(json.dumps(value))
199+
200+
201+
185202
class _GenerateContentInstrumentationHelper:
186203
def __init__(
187204
self,
@@ -198,6 +215,8 @@ def __init__(
198215
self._input_tokens = 0
199216
self._output_tokens = 0
200217
self._content_recording_enabled = is_content_recording_enabled()
218+
self._response_index = 0
219+
self._candidate_index = 0
201220

202221
def start_span_as_current_span(self, model_name, function_name):
203222
return self._otel_wrapper.start_as_current_span(
@@ -235,6 +254,7 @@ def process_response(self, response: GenerateContentResponse):
235254
self._maybe_update_token_counts(response)
236255
self._maybe_update_error_type(response)
237256
self._maybe_log_response(response)
257+
self._response_index += 1
238258

239259
def process_error(self, e: Exception):
240260
self._error_type = str(e.__class__.__name__)
@@ -296,64 +316,145 @@ def _maybe_update_error_type(self, response: GenerateContentResponse):
296316
def _maybe_log_system_instruction(
297317
self, config: Optional[GenerateContentConfigOrDict] = None
298318
):
299-
if not self._content_recording_enabled:
300-
return
301319
system_instruction = _get_config_property(config, "system_instruction")
302320
if not system_instruction:
303321
return
322+
attributes = {
323+
gen_ai_attributes.GEN_AI_SYSTEM: self._genai_system,
324+
}
304325
# TODO: determine if "role" should be reported here or not. It is unclear
305326
# since the caller does not supply a "role" and since this comes through
306327
# a property named "system_instruction" which would seem to align with
307328
# the default "role" that is allowed to be omitted by default.
308329
#
309330
# See also: "TODOS.md"
331+
body = {}
332+
if self._content_recording_enabled:
333+
body["content"] = _to_dict(system_instruction)
334+
else:
335+
body["content"] = _CONTENT_ELIDED
310336
self._otel_wrapper.log_system_prompt(
311-
attributes={
312-
gen_ai_attributes.GEN_AI_SYSTEM: self._genai_system,
313-
},
314-
body={
315-
"content": system_instruction,
316-
},
337+
attributes=attributes,
338+
body=body,
317339
)
318340

319341
def _maybe_log_user_prompt(
320342
self, contents: Union[ContentListUnion, ContentListUnionDict]
321343
):
322-
if not self._content_recording_enabled:
323-
return
344+
if isinstance(contents, list):
345+
total=len(contents)
346+
index=0
347+
for entry in contents:
348+
self._maybe_log_single_user_prompt(entry, index=index, total=total)
349+
index += 1
350+
else:
351+
self._maybe_log_single_user_prompt(contents)
352+
353+
def _maybe_log_single_user_prompt(
354+
self,
355+
contents: Union[ContentUnion, ContentUnionDict],
356+
index=0,
357+
total=1):
358+
# TODO: figure out how to report the index in a manner that is
359+
# aligned with the OTel semantic conventions.
360+
attributes = {
361+
gen_ai_attributes.GEN_AI_SYSTEM: self._genai_system,
362+
}
363+
324364
# TODO: determine if "role" should be reported here or not and, if so,
325365
# what the value ought to be. It is not clear whether there is always
326366
# a role supplied (and it looks like there could be cases where there
327367
# is more than one role present in the supplied contents)?
328368
#
329369
# See also: "TODOS.md"
370+
body = {}
371+
if self._content_recording_enabled:
372+
logged_contents = contents
373+
if isinstance(contents, list):
374+
logged_contents = Content(parts=contents)
375+
body["content"] = _to_dict(logged_contents)
376+
else:
377+
body["content"] = _CONTENT_ELIDED
330378
self._otel_wrapper.log_user_prompt(
331-
attributes={
332-
gen_ai_attributes.GEN_AI_SYSTEM: self._genai_system,
333-
},
334-
body={
335-
"content": contents,
336-
},
379+
attributes=attributes,
380+
body=body,
337381
)
338382

383+
def _maybe_log_response_stats(self, response: GenerateContentResponse):
384+
# TODO: Determine if there is a way that we can log a summary
385+
# of the overall response in a manner that is aligned with
386+
# Semantic Conventions. For example, it would be natural
387+
# to report an event that looks something like:
388+
#
389+
# gen_ai.response.stats {
390+
# response_index: 0,
391+
# candidate_count: 3,
392+
# parts_per_candidate: [
393+
# 3,
394+
# 1,
395+
# 5
396+
# ]
397+
# }
398+
#
399+
pass
400+
401+
def _maybe_log_response_safety_ratings(self, response: GenerateContentResponse):
402+
# TODO: Determine if there is a way that we can log
403+
# the "prompt_feedback". This would be especially useful
404+
# in the case where the response is blocked.
405+
pass
406+
339407
def _maybe_log_response(self, response: GenerateContentResponse):
340-
if not self._content_recording_enabled:
408+
self._maybe_log_response_stats(response)
409+
self._maybe_log_response_safety_ratings(response)
410+
if not response.candidates:
341411
return
412+
candidate_in_response_index = 0
413+
for candidate in response.candidates:
414+
self._maybe_log_response_candidate(
415+
candidate,
416+
flat_candidate_index=self._candidate_index,
417+
candidate_in_response_index=candidate_in_response_index,
418+
response_index=self._response_index)
419+
self._candidate_index += 1
420+
candidate_in_response_index += 1
421+
422+
def _maybe_log_response_candidate(
423+
self,
424+
candidate: Candidate,
425+
flat_candidate_index: int,
426+
candidate_in_response_index: int,
427+
response_index: int):
428+
# TODO: Determine if there might be a way to report the
429+
# response index and candidate response index.
430+
attributes={
431+
gen_ai_attributes.GEN_AI_SYSTEM: self._genai_system,
432+
}
342433
# TODO: determine if "role" should be reported here or not and, if so,
343434
# what the value ought to be.
344435
#
345436
# TODO: extract tool information into a separate tool message.
346437
#
347-
# TODO: determine if/when we need to emit a 'gen_ai.choice' event.
438+
# TODO: determine if/when we need to emit a 'gen_ai.assistant.message' event.
439+
#
440+
# TODO: determine how to report other relevant details in the candidate that
441+
# are not presently captured by Semantic Conventions. For example, the
442+
# "citation_metadata", "grounding_metadata", "logprobs_result", etc.
348443
#
349444
# See also: "TODOS.md"
445+
body={
446+
"index": flat_candidate_index,
447+
}
448+
if self._content_recording_enabled:
449+
if candidate.content:
450+
body["content"] = _to_dict(candidate.content)
451+
else:
452+
body["content"] = _CONTENT_ELIDED
453+
if candidate.finish_reason is not None:
454+
body["finish_reason"] = candidate.finish_reason.name
350455
self._otel_wrapper.log_response_content(
351-
attributes={
352-
gen_ai_attributes.GEN_AI_SYSTEM: self._genai_system,
353-
},
354-
body={
355-
"content": response.model_dump(),
356-
},
456+
attributes=attributes,
457+
body=body,
357458
)
358459

359460
def _record_token_usage_metric(self):

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def log_user_prompt(self, attributes, body):
8484

8585
def log_response_content(self, attributes, body):
8686
_logger.debug("Recording response.")
87-
event_name = "gen_ai.assistant.message"
87+
event_name = "gen_ai.choice"
8888
self._log_event(event_name, attributes, body)
8989

9090
def _log_event(self, event_name, attributes, body):

instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,10 @@ def test_does_not_record_system_prompt_as_log_if_disabled_by_env(self):
163163
self.generate_content(
164164
model="gemini-2.0-flash", contents="Some input", config=config
165165
)
166-
self.otel.assert_does_not_have_event_named("gen_ai.system.message")
166+
self.otel.assert_has_event_named("gen_ai.system.message")
167+
event_record = self.otel.get_event_named("gen_ai.system.message")
168+
self.assertEqual(event_record.attributes["gen_ai.system"], "gemini")
169+
self.assertEqual(event_record.body["content"], "<elided>")
167170

168171
def test_does_not_record_system_prompt_as_log_if_no_system_prompt_present(
169172
self,
@@ -192,16 +195,19 @@ def test_does_not_record_user_prompt_as_log_if_disabled_by_env(self):
192195
)
193196
self.configure_valid_response()
194197
self.generate_content(model="gemini-2.0-flash", contents="Some input")
195-
self.otel.assert_does_not_have_event_named("gen_ai.user.message")
198+
self.otel.assert_has_event_named("gen_ai.user.message")
199+
event_record = self.otel.get_event_named("gen_ai.user.message")
200+
self.assertEqual(event_record.attributes["gen_ai.system"], "gemini")
201+
self.assertEqual(event_record.body["content"], "<elided>")
196202

197203
def test_records_response_as_log(self):
198204
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = (
199205
"true"
200206
)
201207
self.configure_valid_response(response_text="Some response content")
202208
self.generate_content(model="gemini-2.0-flash", contents="Some input")
203-
self.otel.assert_has_event_named("gen_ai.assistant.message")
204-
event_record = self.otel.get_event_named("gen_ai.assistant.message")
209+
self.otel.assert_has_event_named("gen_ai.choice")
210+
event_record = self.otel.get_event_named("gen_ai.choice")
205211
self.assertEqual(event_record.attributes["gen_ai.system"], "gemini")
206212
self.assertIn(
207213
"Some response content", json.dumps(event_record.body["content"])
@@ -213,7 +219,10 @@ def test_does_not_record_response_as_log_if_disabled_by_env(self):
213219
)
214220
self.configure_valid_response(response_text="Some response content")
215221
self.generate_content(model="gemini-2.0-flash", contents="Some input")
216-
self.otel.assert_does_not_have_event_named("gen_ai.assistant.message")
222+
self.otel.assert_has_event_named("gen_ai.choice")
223+
event_record = self.otel.get_event_named("gen_ai.choice")
224+
self.assertEqual(event_record.attributes["gen_ai.system"], "gemini")
225+
self.assertEqual(event_record.body["content"], "<elided>")
217226

218227
def test_records_metrics_data(self):
219228
self.configure_valid_response()

0 commit comments

Comments
 (0)