1313# limitations under the License.
1414
1515import functools
16+ import json
1617import logging
1718import os
1819import time
2122from google .genai .models import AsyncModels , Models
2223from google .genai .types import (
2324 BlockedReason ,
25+ Candidate ,
26+ Content ,
27+ ContentUnion ,
28+ ContentUnionDict ,
2429 ContentListUnion ,
2530 ContentListUnionDict ,
2631 GenerateContentConfigOrDict ,
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+
185202class _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 ):
0 commit comments