From f199af52ee2ce64996673121c668a9b5f650ba06 Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Tue, 28 Oct 2025 16:17:09 +0000 Subject: [PATCH 1/7] Fix a few bugs in gen AI instrumentation --- .../instrumentation/google_genai/dict_util.py | 17 +- .../google_genai/generate_content.py | 173 +++++++++--------- .../generate_content/nonstreaming_base.py | 23 ++- .../tests/utils/test_dict_util.py | 3 + opentelemetry-operations-python | 1 + 5 files changed, 123 insertions(+), 94 deletions(-) create mode 160000 opentelemetry-operations-python diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py index 6f39474edf..d47d301379 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py @@ -189,13 +189,16 @@ def _flatten_compound_value( flatten_functions=flatten_functions, ) if hasattr(value, "model_dump"): - return _flatten_dict( - value.model_dump(), - key_prefix=key, - exclude_keys=exclude_keys, - rename_keys=rename_keys, - flatten_functions=flatten_functions, - ) + try: + return _flatten_dict( + value.model_dump(), + key_prefix=key, + exclude_keys=exclude_keys, + rename_keys=rename_keys, + flatten_functions=flatten_functions, + ) + except TypeError: + return {key: str(value)} return _flatten_compound_value_using_json( key, value, diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py index 4598915dc3..ea87a72f7b 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py @@ -162,30 +162,24 @@ def _to_dict(value: object): if isinstance(value, dict): return value if hasattr(value, "model_dump"): - return value.model_dump() + try: + return value.model_dump() + except TypeError: + return {"ModelName": str(value)} + return json.loads(json.dumps(value)) -def _add_request_options_to_span( - span: Span, +def create_request_attributes( config: Optional[GenerateContentConfigOrDict], + is_experimental_mode: bool, allow_list: AllowList, -): - if config is None: - return - span_context = span.get_span_context() - if not span_context.trace_flags.sampled: - # Avoid potentially costly traversal of config - # options if the span will be dropped, anyway. - return - # Automatically derive attributes from the contents of the - # config object. This ensures that all relevant parameters - # are captured in the telemetry data (except for those - # that are excluded via "exclude_keys"). Dynamic attributes (those - # starting with "gcp.gen_ai." instead of simply "gen_ai.request.") - # are filtered with the "allow_list" before inclusion in the span. +) -> dict[str, Any]: + if not config: + return {} + config = _to_dict(config) attributes = flatten_dict( - _to_dict(config), + config, # A custom prefix is used, because the names/structure of the # configuration is likely to be specific to Google Gen AI SDK. key_prefix=GCP_GENAI_OPERATION_CONFIG, @@ -212,37 +206,21 @@ def _add_request_options_to_span( "gcp.gen_ai.operation.config.seed": gen_ai_attributes.GEN_AI_REQUEST_SEED, }, ) - for key, value in attributes.items(): - if key.startswith( - GCP_GENAI_OPERATION_CONFIG - ) and not allow_list.allowed(key): - # The allowlist is used to control inclusion of the dynamic keys. - continue - span.set_attribute(key, value) - - -def _get_gen_ai_request_attributes( - config: Union[GenerateContentConfigOrDict, None], -) -> dict[str, Any]: - if not config: - return {} - attributes: dict[str, Any] = {} - config = _coerce_config_to_object(config) - if config.seed: - attributes[gen_ai_attributes.GEN_AI_REQUEST_SEED] = config.seed - if config.candidate_count: - attributes[gen_ai_attributes.GEN_AI_REQUEST_CHOICE_COUNT] = ( - config.candidate_count - ) - if config.response_mime_type: - if config.response_mime_type == "text/plain": + response_mime_type = config.get("response_mime_type") + if response_mime_type and is_experimental_mode: + if response_mime_type == "text/plain": attributes[gen_ai_attributes.GEN_AI_OUTPUT_TYPE] = "text" - elif config.response_mime_type == "application/json": + elif response_mime_type == "application/json": attributes[gen_ai_attributes.GEN_AI_OUTPUT_TYPE] = "json" else: attributes[gen_ai_attributes.GEN_AI_OUTPUT_TYPE] = ( - config.response_mime_type + response_mime_type ) + for key in list(attributes.keys()): + if key.startswith( + GCP_GENAI_OPERATION_CONFIG + ) and not allow_list.allowed(key): + del attributes[key] return attributes @@ -372,14 +350,6 @@ def start_span_as_current_span( end_on_exit=end_on_exit, ) - def add_request_options_to_span( - self, config: Optional[GenerateContentConfigOrDict] - ): - span = trace.get_current_span() - _add_request_options_to_span( - span, config, self._generate_content_config_key_allowlist - ) - def process_request( self, contents: Union[ContentListUnion, ContentListUnionDict], @@ -393,17 +363,6 @@ def process_response(self, response: GenerateContentResponse): self._maybe_log_response(response) self._response_index += 1 - def process_completion( - self, - request: Union[ContentListUnion, ContentListUnionDict], - response: GenerateContentResponse, - config: Optional[GenerateContentConfigOrDict] = None, - ): - self._update_response(response) - self._maybe_log_completion_details( - request, response.candidates or [], config - ) - def process_error(self, e: Exception): self._error_type = str(e.__class__.__name__) @@ -488,11 +447,11 @@ def _maybe_update_error_type(self, response: GenerateContentResponse): def _maybe_log_completion_details( self, + request_attributes: dict[str, Any], request: Union[ContentListUnion, ContentListUnionDict], candidates: list[Candidate], config: Optional[GenerateContentConfigOrDict] = None, ): - attributes = _get_gen_ai_request_attributes(config) system_instructions = [] if system_content := _config_to_system_instruction(config): system_instructions = to_system_instructions( @@ -506,7 +465,7 @@ def _maybe_log_completion_details( span = trace.get_current_span() event = LogRecord( event_name="gen_ai.client.inference.operation.details", - attributes=attributes, + attributes=request_attributes, ) self.completion_hook.on_completion( inputs=input_messages, @@ -540,7 +499,7 @@ def _maybe_log_completion_details( for k, v in completion_details_attributes.items() } ) - span.set_attributes(attributes) + # request attributes were already set on the span.. def _maybe_log_system_instruction( self, config: Optional[GenerateContentConfigOrDict] = None @@ -748,6 +707,7 @@ def instrumented_generate_content( config: Optional[GenerateContentConfigOrDict] = None, **kwargs: Any, ) -> GenerateContentResponse: + candidates = [] helper = _GenerateContentInstrumentationHelper( self, otel_wrapper, @@ -755,10 +715,19 @@ def instrumented_generate_content( completion_hook, generate_content_config_key_allowlist=generate_content_config_key_allowlist, ) + is_experimental_mode = ( + helper.sem_conv_opt_in_mode + == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL + ) + request_attributes = create_request_attributes( + config, + is_experimental_mode, + helper._generate_content_config_key_allowlist, + ) with helper.start_span_as_current_span( model, "google.genai.Models.generate_content" - ): - helper.add_request_options_to_span(config) + ) as span: + span.set_attributes(request_attributes) if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: helper.process_request(contents, config) try: @@ -771,11 +740,9 @@ def instrumented_generate_content( ) if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: helper.process_response(response) - elif ( - helper.sem_conv_opt_in_mode - == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL - ): - helper.process_completion(contents, response, config) + elif is_experimental_mode: + candidates = response.candidates + helper._update_response(response) else: raise ValueError( f"Sem Conv opt in mode {helper.sem_conv_opt_in_mode} not supported." @@ -785,6 +752,9 @@ def instrumented_generate_content( helper.process_error(error) raise finally: + helper._maybe_log_completion_details( + request_attributes, contents, candidates, config + ) helper.finalize_processing() return instrumented_generate_content @@ -815,10 +785,19 @@ def instrumented_generate_content_stream( completion_hook, generate_content_config_key_allowlist=generate_content_config_key_allowlist, ) + is_experimental_mode = ( + helper.sem_conv_opt_in_mode + == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL + ) + request_attributes = create_request_attributes( + config, + is_experimental_mode, + helper._generate_content_config_key_allowlist, + ) with helper.start_span_as_current_span( model, "google.genai.Models.generate_content_stream" - ): - helper.add_request_options_to_span(config) + ) as span: + span.set_attributes(request_attributes) if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: helper.process_request(contents, config) try: @@ -831,10 +810,7 @@ def instrumented_generate_content_stream( ): if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: helper.process_response(response) - elif ( - helper.sem_conv_opt_in_mode - == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL - ): + elif is_experimental_mode: helper._update_response(response) if response.candidates: candidates += response.candidates @@ -848,7 +824,7 @@ def instrumented_generate_content_stream( raise finally: helper._maybe_log_completion_details( - contents, candidates, config + request_attributes, contents, candidates, config ) helper.finalize_processing() @@ -879,10 +855,20 @@ async def instrumented_generate_content( completion_hook, generate_content_config_key_allowlist=generate_content_config_key_allowlist, ) + is_experimental_mode = ( + helper.sem_conv_opt_in_mode + == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL + ) + request_attributes = create_request_attributes( + config, + is_experimental_mode, + helper._generate_content_config_key_allowlist, + ) + candidates: list[Candidate] = [] with helper.start_span_as_current_span( model, "google.genai.AsyncModels.generate_content" - ): - helper.add_request_options_to_span(config) + ) as span: + span.set_attributes(request_attributes) if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: helper.process_request(contents, config) try: @@ -899,7 +885,9 @@ async def instrumented_generate_content( helper.sem_conv_opt_in_mode == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL ): - helper.process_completion(contents, response, config) + helper._update_response(response) + if response.candidates: + candidates += response.candidates else: raise ValueError( f"Sem Conv opt in mode {helper.sem_conv_opt_in_mode} not supported." @@ -909,6 +897,12 @@ async def instrumented_generate_content( helper.process_error(error) raise finally: + helper._maybe_log_completion_details( + request_attributes, + contents, + candidates, + config, + ) helper.finalize_processing() return instrumented_generate_content @@ -939,12 +933,21 @@ async def instrumented_generate_content_stream( completion_hook, generate_content_config_key_allowlist=generate_content_config_key_allowlist, ) + is_experimental_mode = ( + helper.sem_conv_opt_in_mode + == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL + ) + request_attributes = create_request_attributes( + config, + is_experimental_mode, + helper._generate_content_config_key_allowlist, + ) with helper.start_span_as_current_span( model, "google.genai.AsyncModels.generate_content_stream", end_on_exit=False, ) as span: - helper.add_request_options_to_span(config) + span.set_attributes(request_attributes) if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: helper.process_request(contents, config) try: @@ -988,7 +991,7 @@ async def _response_async_generator_wrapper(): raise finally: helper._maybe_log_completion_details( - contents, candidates, config + request_attributes, contents, candidates, config ) helper.finalize_processing() diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py index 4ae6f00063..848768725f 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py @@ -17,6 +17,7 @@ from unittest.mock import patch from google.genai.types import GenerateContentConfig +from pydantic import BaseModel, Field from opentelemetry._events import Event from opentelemetry.instrumentation._semconv import ( @@ -32,6 +33,10 @@ from .base import TestCase +class ExampleResponseSchema(BaseModel): + name: str = Field(description="A Destination's Name") + + class NonStreamingTestCase(TestCase): # The "setUp" function is defined by "unittest.TestCase" and thus # this name must be used. Uncertain why pylint doesn't seem to @@ -215,6 +220,12 @@ def test_does_not_record_response_as_log_if_disabled_by_env(self): self.assertEqual(event_record.attributes["gen_ai.system"], "gemini") self.assertEqual(event_record.body["content"], "") + @patch.dict( + "os.environ", + { + "OTEL_GOOGLE_GENAI_GENERATE_CONTENT_CONFIG_INCLUDES": "gcp.gen_ai.operation.config.response_schema" + }, + ) def test_new_semconv_record_completion_as_log(self): for mode in ContentCapturingMode: patched_environ = patch.dict( @@ -243,7 +254,8 @@ def test_new_semconv_record_completion_as_log(self): model="gemini-2.0-flash", contents=content, config=GenerateContentConfig( - system_instruction=sys_instr + system_instruction=sys_instr, + response_schema=ExampleResponseSchema, ), ) self.otel.assert_has_event_named( @@ -252,6 +264,12 @@ def test_new_semconv_record_completion_as_log(self): event = self.otel.get_event_named( "gen_ai.client.inference.operation.details" ) + assert ( + event.attributes[ + "gcp.gen_ai.operation.config.response_schema" + ] + == "" + ) if mode in [ ContentCapturingMode.NO_CONTENT, ContentCapturingMode.SPAN_ONLY, @@ -346,7 +364,8 @@ def test_new_semconv_record_completion_in_span(self): model="gemini-2.0-flash", contents="Some input", config=GenerateContentConfig( - system_instruction="System instruction" + system_instruction="System instruction", + response_schema=ExampleResponseSchema, ), ) span = self.otel.get_span_named( diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_dict_util.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_dict_util.py index ef2e641360..7652b7a391 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_dict_util.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_dict_util.py @@ -172,6 +172,9 @@ def test_flatten_with_pydantic_model_value(): "foo.str_value": "bar", "foo.int_value": 123, } + assert dict_util.flatten_dict({"foo": PydanticModel}) == { + "foo": "" + } def test_flatten_with_model_dumpable_value(): diff --git a/opentelemetry-operations-python b/opentelemetry-operations-python new file mode 160000 index 0000000000..6358cf5626 --- /dev/null +++ b/opentelemetry-operations-python @@ -0,0 +1 @@ +Subproject commit 6358cf56263a875224c3db7fee79b40144866f15 From 3ed2432160cd4c506c05d8387cd066a974019b95 Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Wed, 29 Oct 2025 20:17:05 +0000 Subject: [PATCH 2/7] Make a lot of changes --- .../CHANGELOG.md | 2 + .../google_genai/generate_content.py | 170 +++++++++++------- .../tests/common/base.py | 15 ++ .../tests/generate_content/base.py | 28 ++- .../generate_content/nonstreaming_base.py | 35 ++++ .../tests/utils/test_tool_call_wrapper.py | 60 +++---- 6 files changed, 202 insertions(+), 108 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md b/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md index 85f00eebd6..4fee436fc9 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Fixes bugs + ## Version 0.4b0 (2025-10-16) - Implement the new semantic convention changes made in https://github.com/open-telemetry/semantic-conventions/pull/2179. diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py index ea87a72f7b..b2b88a45fb 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py @@ -350,6 +350,18 @@ def start_span_as_current_span( end_on_exit=end_on_exit, ) + def create_final_attributes(self) -> dict[str, Any]: + final_attributes = { + gen_ai_attributes.GEN_AI_USAGE_INPUT_TOKENS: self._input_tokens, + gen_ai_attributes.GEN_AI_USAGE_OUTPUT_TOKENS: self._output_tokens, + gen_ai_attributes.GEN_AI_RESPONSE_FINISH_REASONS: sorted( + self._finish_reasons_set + ), + } + if self._error_type: + final_attributes[error_attributes.ERROR_TYPE] = self._error_type + return final_attributes + def process_request( self, contents: Union[ContentListUnion, ContentListUnionDict], @@ -366,25 +378,6 @@ def process_response(self, response: GenerateContentResponse): def process_error(self, e: Exception): self._error_type = str(e.__class__.__name__) - def finalize_processing(self): - span = trace.get_current_span() - span.set_attribute( - gen_ai_attributes.GEN_AI_USAGE_INPUT_TOKENS, self._input_tokens - ) - span.set_attribute( - gen_ai_attributes.GEN_AI_USAGE_OUTPUT_TOKENS, self._output_tokens - ) - span.set_attribute( - gen_ai_attributes.GEN_AI_RESPONSE_FINISH_REASONS, - sorted(self._finish_reasons_set), - ) - if self.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: - span.set_attribute( - gen_ai_attributes.GEN_AI_SYSTEM, self._genai_system - ) - self._record_token_usage_metric() - self._record_duration_metric() - def _update_response(self, response: GenerateContentResponse): # TODO: Determine if there are other response properties that # need to be reflected back into the span attributes. @@ -448,10 +441,15 @@ def _maybe_update_error_type(self, response: GenerateContentResponse): def _maybe_log_completion_details( self, request_attributes: dict[str, Any], + final_attributes: dict[str, Any], + is_experimental_mode: bool, request: Union[ContentListUnion, ContentListUnionDict], candidates: list[Candidate], config: Optional[GenerateContentConfigOrDict] = None, ): + if not is_experimental_mode: + print("not experimental mode?") + return system_instructions = [] if system_content := _config_to_system_instruction(config): system_instructions = to_system_instructions( @@ -465,7 +463,7 @@ def _maybe_log_completion_details( span = trace.get_current_span() event = LogRecord( event_name="gen_ai.client.inference.operation.details", - attributes=request_attributes, + attributes=request_attributes | final_attributes, ) self.completion_hook.on_completion( inputs=input_messages, @@ -487,6 +485,7 @@ def _maybe_log_completion_details( **(event.attributes or {}), **completion_details_attributes, } + print("writing completion event..") self._otel_wrapper.log_completion_details(event=event) if self._content_recording_enabled in [ @@ -707,6 +706,7 @@ def instrumented_generate_content( config: Optional[GenerateContentConfigOrDict] = None, **kwargs: Any, ) -> GenerateContentResponse: + print("in instrumented code..") candidates = [] helper = _GenerateContentInstrumentationHelper( self, @@ -719,6 +719,7 @@ def instrumented_generate_content( helper.sem_conv_opt_in_mode == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL ) + print(f"opt in mode: {helper.sem_conv_opt_in_mode}") request_attributes = create_request_attributes( config, is_experimental_mode, @@ -730,7 +731,11 @@ def instrumented_generate_content( span.set_attributes(request_attributes) if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: helper.process_request(contents, config) + span.set_attribute( + gen_ai_attributes.GEN_AI_SYSTEM, helper._genai_system + ) try: + print("trying to get resp..") response = wrapped_func( self, model=model, @@ -738,24 +743,33 @@ def instrumented_generate_content( config=helper.wrapped_config(config), **kwargs, ) - if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: - helper.process_response(response) - elif is_experimental_mode: - candidates = response.candidates + print("resp over..") + if is_experimental_mode: helper._update_response(response) + if response.candidates: + candidates += response.candidates + else: - raise ValueError( - f"Sem Conv opt in mode {helper.sem_conv_opt_in_mode} not supported." - ) + helper.process_response(response) return response except Exception as error: + print("EXCEPTION RIASED.. PROCESSING ERROR>>>") helper.process_error(error) raise finally: + print("in the finnally block..") + final_attributes = helper.create_final_attributes() + span.set_attributes(final_attributes) helper._maybe_log_completion_details( - request_attributes, contents, candidates, config + request_attributes, + final_attributes, + is_experimental_mode, + contents, + candidates, + config, ) - helper.finalize_processing() + helper._record_token_usage_metric() + helper._record_duration_metric() return instrumented_generate_content @@ -800,6 +814,9 @@ def instrumented_generate_content_stream( span.set_attributes(request_attributes) if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: helper.process_request(contents, config) + span.set_attribute( + gen_ai_attributes.GEN_AI_SYSTEM, helper._genai_system + ) try: for response in wrapped_func( self, @@ -808,25 +825,30 @@ def instrumented_generate_content_stream( config=helper.wrapped_config(config), **kwargs, ): - if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: - helper.process_response(response) - elif is_experimental_mode: + if is_experimental_mode: helper._update_response(response) if response.candidates: candidates += response.candidates + else: - raise ValueError( - f"Sem Conv opt in mode {helper.sem_conv_opt_in_mode} not supported." - ) + helper.process_response(response) yield response except Exception as error: helper.process_error(error) raise finally: + final_attributes = helper.create_final_attributes() + span.set_attributes(final_attributes) helper._maybe_log_completion_details( - request_attributes, contents, candidates, config + request_attributes, + final_attributes, + is_experimental_mode, + contents, + candidates, + config, ) - helper.finalize_processing() + helper._record_token_usage_metric() + helper._record_duration_metric() return instrumented_generate_content_stream @@ -871,6 +893,9 @@ async def instrumented_generate_content( span.set_attributes(request_attributes) if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: helper.process_request(contents, config) + span.set_attribute( + gen_ai_attributes.GEN_AI_SYSTEM, helper._genai_system + ) try: response = await wrapped_func( self, @@ -879,31 +904,29 @@ async def instrumented_generate_content( config=helper.wrapped_config(config), **kwargs, ) - if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: - helper.process_response(response) - elif ( - helper.sem_conv_opt_in_mode - == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL - ): + if is_experimental_mode: helper._update_response(response) if response.candidates: candidates += response.candidates else: - raise ValueError( - f"Sem Conv opt in mode {helper.sem_conv_opt_in_mode} not supported." - ) + helper.process_response(response) return response except Exception as error: helper.process_error(error) raise finally: + final_attributes = helper.create_final_attributes() + span.set_attributes(final_attributes) helper._maybe_log_completion_details( request_attributes, + final_attributes, + is_experimental_mode, contents, candidates, config, ) - helper.finalize_processing() + helper._record_token_usage_metric() + helper._record_duration_metric() return instrumented_generate_content @@ -948,8 +971,11 @@ async def instrumented_generate_content_stream( end_on_exit=False, ) as span: span.set_attributes(request_attributes) - if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: + if not is_experimental_mode: helper.process_request(contents, config) + span.set_attribute( + gen_ai_attributes.GEN_AI_SYSTEM, helper._genai_system + ) try: response_async_generator = await wrapped_func( self, @@ -960,7 +986,18 @@ async def instrumented_generate_content_stream( ) except Exception as error: # pylint: disable=broad-exception-caught helper.process_error(error) - helper.finalize_processing() + helper._record_token_usage_metric() + final_attributes = helper.create_final_attributes() + span.set_attributes(final_attributes) + helper._maybe_log_completion_details( + request_attributes, + final_attributes, + is_experimental_mode, + contents, + [], + config, + ) + helper._record_duration_metric() with trace.use_span(span, end_on_exit=True): raise @@ -969,31 +1006,30 @@ async def _response_async_generator_wrapper(): with trace.use_span(span, end_on_exit=True): try: async for response in response_async_generator: - if ( - helper.sem_conv_opt_in_mode - == _StabilityMode.DEFAULT - ): - helper.process_response(response) - elif ( - helper.sem_conv_opt_in_mode - == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL - ): + if is_experimental_mode: helper._update_response(response) if response.candidates: candidates += response.candidates + else: - raise ValueError( - f"Sem Conv opt in mode {helper.sem_conv_opt_in_mode} not supported." - ) + helper.process_response(response) yield response except Exception as error: helper.process_error(error) raise finally: + final_attributes = helper.create_final_attributes() + span.set_attributes(final_attributes) helper._maybe_log_completion_details( - request_attributes, contents, candidates, config + request_attributes, + final_attributes, + is_experimental_mode, + contents, + candidates, + config, ) - helper.finalize_processing() + helper._record_token_usage_metric() + helper._record_duration_metric() return _response_async_generator_wrapper() @@ -1010,6 +1046,14 @@ def instrument_generate_content( completion_hook: CompletionHook, generate_content_config_key_allowlist: Optional[AllowList] = None, ) -> object: + opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.GEN_AI + ) + if ( + opt_in_mode != _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL + and opt_in_mode != _StabilityMode.DEFAULT + ): + raise ValueError(f"Sem Conv opt in mode {opt_in_mode} not supported.") snapshot = _MethodsSnapshot() Models.generate_content = _create_instrumented_generate_content( snapshot, diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/common/base.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/common/base.py index 2bb686e057..7ed9845cc9 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/common/base.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/common/base.py @@ -14,9 +14,14 @@ import os import unittest +from unittest.mock import patch import google.genai +from opentelemetry.instrumentation._semconv import ( + _OpenTelemetrySemanticConventionStability, +) + from .auth import FakeCredentials from .instrumentation_context import InstrumentationContext from .otel_mocker import OTelMocker @@ -24,6 +29,16 @@ class TestCase(unittest.TestCase): def setUp(self): + # Most tests want this environment variable setup. Need to figure out a less hacky way of doing this. + with patch.dict( + "os.environ", + { + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true", + "OTEL_SEMCONV_STABILITY_OPT_IN": "default", + }, + ): + _OpenTelemetrySemanticConventionStability._initialized = False + _OpenTelemetrySemanticConventionStability._initialize() self._otel = OTelMocker() self._otel.install() self._instrumentation_context = None diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/base.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/base.py index 59f08a5e44..7330e0213f 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/base.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/base.py @@ -94,7 +94,10 @@ def configure_valid_response(self, **kwargs): response = create_response(**kwargs) self._responses.append(response) - def _create_and_install_mocks(self): + def configure_exception(self, e, **kwargs): + self._create_and_install_mocks(e) + + def _create_and_install_mocks_with(self): if self._generate_content_mock is not None: return self.reset_client() @@ -103,7 +106,16 @@ def _create_and_install_mocks(self): self._generate_content_stream_mock = self._create_stream_mock() self._install_mocks() - def _create_nonstream_mock(self): + def _create_and_install_mocks(self, e=None): + if self._generate_content_mock is not None: + return + self.reset_client() + self.reset_instrumentation() + self._generate_content_mock = self._create_nonstream_mock(e) + self._generate_content_stream_mock = self._create_stream_mock(e) + self._install_mocks() + + def _create_nonstream_mock(self, e=None): mock = unittest.mock.MagicMock() def _default_impl(*args, **kwargs): @@ -114,17 +126,23 @@ def _default_impl(*args, **kwargs): self._response_index += 1 return result - mock.side_effect = _default_impl + if not e: + mock.side_effect = _default_impl + else: + mock.side_effect = e return mock - def _create_stream_mock(self): + def _create_stream_mock(self, e=None): mock = unittest.mock.MagicMock() def _default_impl(*args, **kwargs): for response in self._responses: yield response - mock.side_effect = _default_impl + if not e: + mock.side_effect = _default_impl + else: + mock.side_effect = e return mock def _install_mocks(self): diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py index 848768725f..677e1edb4f 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py @@ -97,6 +97,41 @@ def test_generated_span_has_minimal_genai_attributes(self): span.attributes["gen_ai.operation.name"], "generate_content" ) + def test_span_and_event_still_written_when_response_is_exception(self): + self.configure_exception(ValueError("Uh oh!")) + patched_environ = patch.dict( + "os.environ", + { + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_AND_EVENT", + "OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental", + }, + ) + with patched_environ: + _OpenTelemetrySemanticConventionStability._initialized = False + _OpenTelemetrySemanticConventionStability._initialize() + try: + self.generate_content( + model="gemini-2.0-flash", contents="Does this work?" + ) + except ValueError: + self.otel.assert_has_span_named( + "generate_content gemini-2.0-flash" + ) + span = self.otel.get_span_named( + "generate_content gemini-2.0-flash" + ) + self.otel.assert_has_event_named( + "gen_ai.client.inference.operation.details" + ) + event = self.otel.get_event_named( + "gen_ai.client.inference.operation.details" + ) + assert ( + span.attributes["error.type"] + == event.attributes["error.type"] + == "ValueError" + ) + def test_generated_span_has_correct_function_name(self): self.configure_valid_response(text="Yep, it works!") self.generate_content( diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py index af5dcef29e..fb94a426d9 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py @@ -13,6 +13,7 @@ # limitations under the License. import asyncio +import os import unittest from unittest.mock import patch @@ -21,8 +22,6 @@ from opentelemetry._logs import get_logger_provider from opentelemetry.instrumentation._semconv import ( _OpenTelemetrySemanticConventionStability, - _OpenTelemetryStabilitySignalType, - _StabilityMode, ) from opentelemetry.instrumentation.google_genai import ( otel_wrapper, @@ -44,6 +43,12 @@ def setUp(self): get_logger_provider(), get_meter_provider(), ) + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "true" + ) + os.environ["OTEL_SEMCONV_STABILITY_OPT_IN"] = "default" + _OpenTelemetrySemanticConventionStability._initialized = False + _OpenTelemetrySemanticConventionStability._initialize() @property def otel(self): @@ -169,10 +174,6 @@ def somefunction(): "An example tool call function.", ) - @patch.dict( - "os.environ", - {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, - ) def test_handles_primitive_int_arg(self): def somefunction(arg=None): pass @@ -191,10 +192,6 @@ def somefunction(arg=None): span.attributes["code.function.parameters.arg.value"], 12345 ) - @patch.dict( - "os.environ", - {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, - ) def test_handles_primitive_string_arg(self): def somefunction(arg=None): pass @@ -214,10 +211,6 @@ def somefunction(arg=None): "a string value", ) - @patch.dict( - "os.environ", - {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, - ) def test_handles_dict_arg(self): def somefunction(arg=None): pass @@ -237,10 +230,6 @@ def somefunction(arg=None): '{"key": "value"}', ) - @patch.dict( - "os.environ", - {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, - ) def test_handles_primitive_list_arg(self): def somefunction(arg=None): pass @@ -262,10 +251,6 @@ def somefunction(arg=None): [1, 2, 3], ) - @patch.dict( - "os.environ", - {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, - ) def test_handles_heterogenous_list_arg(self): def somefunction(arg=None): pass @@ -290,24 +275,19 @@ def somefunction(arg=None): pass for mode in ContentCapturingMode: - patched_environ = patch.dict( - "os.environ", - { - "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": mode.name, - "OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental", - }, - ) - patched_otel_mapping = patch.dict( - _OpenTelemetrySemanticConventionStability._OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING, - { - _OpenTelemetryStabilitySignalType.GEN_AI: _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL - }, - ) - with self.subTest( - f"mode: {mode}", patched_environ=patched_environ - ): + with self.subTest(f"mode: {mode}"): self.setUp() - with patched_environ, patched_otel_mapping: + with patch.dict( + "os.environ", + { + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": mode.name, + "OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental", + }, + ): + _OpenTelemetrySemanticConventionStability._initialized = ( + False + ) + _OpenTelemetrySemanticConventionStability._initialize() wrapped_somefunction = self.wrap(somefunction) wrapped_somefunction(12345) @@ -328,4 +308,4 @@ def somefunction(arg=None): "code.function.parameters.arg.value", span.attributes, ) - self.tearDown() + self.tearDown() From 332895e0253a002754ae9e06ce02635b23432bfa Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Wed, 29 Oct 2025 20:33:19 +0000 Subject: [PATCH 3/7] Remove print statements --- .../instrumentation/google_genai/generate_content.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py index b2b88a45fb..72f974827b 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py @@ -448,7 +448,6 @@ def _maybe_log_completion_details( config: Optional[GenerateContentConfigOrDict] = None, ): if not is_experimental_mode: - print("not experimental mode?") return system_instructions = [] if system_content := _config_to_system_instruction(config): @@ -485,7 +484,6 @@ def _maybe_log_completion_details( **(event.attributes or {}), **completion_details_attributes, } - print("writing completion event..") self._otel_wrapper.log_completion_details(event=event) if self._content_recording_enabled in [ @@ -706,7 +704,6 @@ def instrumented_generate_content( config: Optional[GenerateContentConfigOrDict] = None, **kwargs: Any, ) -> GenerateContentResponse: - print("in instrumented code..") candidates = [] helper = _GenerateContentInstrumentationHelper( self, @@ -719,7 +716,6 @@ def instrumented_generate_content( helper.sem_conv_opt_in_mode == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL ) - print(f"opt in mode: {helper.sem_conv_opt_in_mode}") request_attributes = create_request_attributes( config, is_experimental_mode, @@ -735,7 +731,6 @@ def instrumented_generate_content( gen_ai_attributes.GEN_AI_SYSTEM, helper._genai_system ) try: - print("trying to get resp..") response = wrapped_func( self, model=model, @@ -743,7 +738,6 @@ def instrumented_generate_content( config=helper.wrapped_config(config), **kwargs, ) - print("resp over..") if is_experimental_mode: helper._update_response(response) if response.candidates: @@ -753,11 +747,9 @@ def instrumented_generate_content( helper.process_response(response) return response except Exception as error: - print("EXCEPTION RIASED.. PROCESSING ERROR>>>") helper.process_error(error) raise finally: - print("in the finnally block..") final_attributes = helper.create_final_attributes() span.set_attributes(final_attributes) helper._maybe_log_completion_details( From b8be0b1d1aa86a37f9ff8a5465a8515bf76fc234 Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Thu, 30 Oct 2025 13:45:52 +0000 Subject: [PATCH 4/7] fix lint issues --- .../opentelemetry/instrumentation/google_genai/dict_util.py | 2 +- .../instrumentation/google_genai/generate_content.py | 6 +++--- .../tests/generate_content/nonstreaming_base.py | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py index d47d301379..037311626e 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py @@ -151,7 +151,7 @@ def _flatten_compound_value_using_json( ) -def _flatten_compound_value( +def _flatten_compound_value( # pylint: disable=too-many-return-statements key: str, value: Any, exclude_keys: Set[str], diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py index 72f974827b..19bbe9e6e1 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py @@ -1041,9 +1041,9 @@ def instrument_generate_content( opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( _OpenTelemetryStabilitySignalType.GEN_AI ) - if ( - opt_in_mode != _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL - and opt_in_mode != _StabilityMode.DEFAULT + if opt_in_mode not in ( + _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL, + _StabilityMode.DEFAULT, ): raise ValueError(f"Sem Conv opt in mode {opt_in_mode} not supported.") snapshot = _MethodsSnapshot() diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py index 677e1edb4f..47fdd96a2e 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py @@ -32,6 +32,8 @@ from .base import TestCase +# pylint: disable=too-many-public-methods + class ExampleResponseSchema(BaseModel): name: str = Field(description="A Destination's Name") From 863b097cd09af6052483a0a696c383eb02d41d95 Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Thu, 30 Oct 2025 14:43:27 +0000 Subject: [PATCH 5/7] remove added folder --- opentelemetry-operations-python | 1 - 1 file changed, 1 deletion(-) delete mode 160000 opentelemetry-operations-python diff --git a/opentelemetry-operations-python b/opentelemetry-operations-python deleted file mode 160000 index 6358cf5626..0000000000 --- a/opentelemetry-operations-python +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6358cf56263a875224c3db7fee79b40144866f15 From c416c5f4042549cc124d216a40dbc7eb5b7cfbbc Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Tue, 11 Nov 2025 16:00:32 +0000 Subject: [PATCH 6/7] Address comments --- .../CHANGELOG.md | 6 +-- .../google_genai/generate_content.py | 21 +++++----- .../tests/generate_content/base.py | 14 +------ .../generate_content/nonstreaming_base.py | 38 +++++++++---------- pyproject.toml | 1 + util/opentelemetry-util-genai/CHANGELOG.md | 2 + 6 files changed, 35 insertions(+), 47 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md b/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md index 6cbc551712..7f0e70877a 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md @@ -7,9 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- Minor change to check LRU cache in Completion Hook before acquiring semaphore/thread ([#3907](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3907)). - -- Fixes bugs +- Ensure log event is written and completion hook is called even when model call results in exception. Put new +log event (` gen_ai.client.inference.operation.details`) behind the flag `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental`. +Ensure same sem conv attributes are on the log and span. Fix an issue where the instrumentation would crash when a pydantic.BaseModel class was passed as the response schema ([#3905](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3905)). ## Version 0.4b0 (2025-10-16) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py index 19bbe9e6e1..405f054219 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py @@ -170,7 +170,7 @@ def _to_dict(value: object): return json.loads(json.dumps(value)) -def create_request_attributes( +def _create_request_attributes( config: Optional[GenerateContentConfigOrDict], is_experimental_mode: bool, allow_list: AllowList, @@ -442,12 +442,14 @@ def _maybe_log_completion_details( self, request_attributes: dict[str, Any], final_attributes: dict[str, Any], - is_experimental_mode: bool, request: Union[ContentListUnion, ContentListUnionDict], candidates: list[Candidate], config: Optional[GenerateContentConfigOrDict] = None, ): - if not is_experimental_mode: + if ( + self.sem_conv_opt_in_mode + != _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL + ): return system_instructions = [] if system_content := _config_to_system_instruction(config): @@ -716,7 +718,7 @@ def instrumented_generate_content( helper.sem_conv_opt_in_mode == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL ) - request_attributes = create_request_attributes( + request_attributes = _create_request_attributes( config, is_experimental_mode, helper._generate_content_config_key_allowlist, @@ -755,7 +757,6 @@ def instrumented_generate_content( helper._maybe_log_completion_details( request_attributes, final_attributes, - is_experimental_mode, contents, candidates, config, @@ -795,7 +796,7 @@ def instrumented_generate_content_stream( helper.sem_conv_opt_in_mode == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL ) - request_attributes = create_request_attributes( + request_attributes = _create_request_attributes( config, is_experimental_mode, helper._generate_content_config_key_allowlist, @@ -834,7 +835,6 @@ def instrumented_generate_content_stream( helper._maybe_log_completion_details( request_attributes, final_attributes, - is_experimental_mode, contents, candidates, config, @@ -873,7 +873,7 @@ async def instrumented_generate_content( helper.sem_conv_opt_in_mode == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL ) - request_attributes = create_request_attributes( + request_attributes = _create_request_attributes( config, is_experimental_mode, helper._generate_content_config_key_allowlist, @@ -912,7 +912,6 @@ async def instrumented_generate_content( helper._maybe_log_completion_details( request_attributes, final_attributes, - is_experimental_mode, contents, candidates, config, @@ -952,7 +951,7 @@ async def instrumented_generate_content_stream( helper.sem_conv_opt_in_mode == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL ) - request_attributes = create_request_attributes( + request_attributes = _create_request_attributes( config, is_experimental_mode, helper._generate_content_config_key_allowlist, @@ -984,7 +983,6 @@ async def instrumented_generate_content_stream( helper._maybe_log_completion_details( request_attributes, final_attributes, - is_experimental_mode, contents, [], config, @@ -1015,7 +1013,6 @@ async def _response_async_generator_wrapper(): helper._maybe_log_completion_details( request_attributes, final_attributes, - is_experimental_mode, contents, candidates, config, diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/base.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/base.py index 7330e0213f..ebaeeb8748 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/base.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/base.py @@ -97,15 +97,6 @@ def configure_valid_response(self, **kwargs): def configure_exception(self, e, **kwargs): self._create_and_install_mocks(e) - def _create_and_install_mocks_with(self): - if self._generate_content_mock is not None: - return - self.reset_client() - self.reset_instrumentation() - self._generate_content_mock = self._create_nonstream_mock() - self._generate_content_stream_mock = self._create_stream_mock() - self._install_mocks() - def _create_and_install_mocks(self, e=None): if self._generate_content_mock is not None: return @@ -126,10 +117,7 @@ def _default_impl(*args, **kwargs): self._response_index += 1 return result - if not e: - mock.side_effect = _default_impl - else: - mock.side_effect = e + mock.side_effect = e or _default_impl return mock def _create_stream_mock(self, e=None): diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py index 47fdd96a2e..0520f818f9 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py @@ -16,6 +16,7 @@ import unittest from unittest.mock import patch +import pytest from google.genai.types import GenerateContentConfig from pydantic import BaseModel, Field @@ -111,28 +112,27 @@ def test_span_and_event_still_written_when_response_is_exception(self): with patched_environ: _OpenTelemetrySemanticConventionStability._initialized = False _OpenTelemetrySemanticConventionStability._initialize() - try: + with pytest.raises(ValueError): self.generate_content( model="gemini-2.0-flash", contents="Does this work?" ) - except ValueError: - self.otel.assert_has_span_named( - "generate_content gemini-2.0-flash" - ) - span = self.otel.get_span_named( - "generate_content gemini-2.0-flash" - ) - self.otel.assert_has_event_named( - "gen_ai.client.inference.operation.details" - ) - event = self.otel.get_event_named( - "gen_ai.client.inference.operation.details" - ) - assert ( - span.attributes["error.type"] - == event.attributes["error.type"] - == "ValueError" - ) + self.otel.assert_has_span_named( + "generate_content gemini-2.0-flash" + ) + span = self.otel.get_span_named( + "generate_content gemini-2.0-flash" + ) + self.otel.assert_has_event_named( + "gen_ai.client.inference.operation.details" + ) + event = self.otel.get_event_named( + "gen_ai.client.inference.operation.details" + ) + assert ( + span.attributes["error.type"] + == event.attributes["error.type"] + == "ValueError" + ) def test_generated_span_has_correct_function_name(self): self.configure_valid_response(text="Yep, it works!") diff --git a/pyproject.toml b/pyproject.toml index aebb47166b..ffd0c47d13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -198,6 +198,7 @@ pythonVersion = "3.9" reportPrivateUsage = false # Ignore private attributes added by instrumentation packages. # Add progressively instrumentation packages here. include = [ + "instrumentation-genai/opentelemetry-instrumentation-google-genai", "instrumentation/opentelemetry-instrumentation-aiokafka", "instrumentation/opentelemetry-instrumentation-asyncclick", "instrumentation/opentelemetry-instrumentation-threading", diff --git a/util/opentelemetry-util-genai/CHANGELOG.md b/util/opentelemetry-util-genai/CHANGELOG.md index 873df73a1d..8f1326a9a4 100644 --- a/util/opentelemetry-util-genai/CHANGELOG.md +++ b/util/opentelemetry-util-genai/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Minor change to check LRU cache in Completion Hook before acquiring semaphore/thread ([#3907](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3907)). + ## Version 0.2b0 (2025-10-14) - Add jsonlines support to fsspec uploader From d11df59fa027128c166f9cae27dae0fd4822a72c Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Tue, 11 Nov 2025 16:38:22 +0000 Subject: [PATCH 7/7] Move code into helper --- .../google_genai/generate_content.py | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py index 405f054219..82dda55d17 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py @@ -366,7 +366,9 @@ def process_request( self, contents: Union[ContentListUnion, ContentListUnionDict], config: Optional[GenerateContentConfigOrDict], + span: Span, ): + span.set_attribute(gen_ai_attributes.GEN_AI_SYSTEM, self._genai_system) self._maybe_log_system_instruction(config=config) self._maybe_log_user_prompt(contents) @@ -728,10 +730,7 @@ def instrumented_generate_content( ) as span: span.set_attributes(request_attributes) if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: - helper.process_request(contents, config) - span.set_attribute( - gen_ai_attributes.GEN_AI_SYSTEM, helper._genai_system - ) + helper.process_request(contents, config, span) try: response = wrapped_func( self, @@ -806,10 +805,7 @@ def instrumented_generate_content_stream( ) as span: span.set_attributes(request_attributes) if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: - helper.process_request(contents, config) - span.set_attribute( - gen_ai_attributes.GEN_AI_SYSTEM, helper._genai_system - ) + helper.process_request(contents, config, span) try: for response in wrapped_func( self, @@ -884,10 +880,7 @@ async def instrumented_generate_content( ) as span: span.set_attributes(request_attributes) if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: - helper.process_request(contents, config) - span.set_attribute( - gen_ai_attributes.GEN_AI_SYSTEM, helper._genai_system - ) + helper.process_request(contents, config, span) try: response = await wrapped_func( self, @@ -963,10 +956,7 @@ async def instrumented_generate_content_stream( ) as span: span.set_attributes(request_attributes) if not is_experimental_mode: - helper.process_request(contents, config) - span.set_attribute( - gen_ai_attributes.GEN_AI_SYSTEM, helper._genai_system - ) + helper.process_request(contents, config, span) try: response_async_generator = await wrapped_func( self,