diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md b/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md index 13e8d32fcd..7f0e70877a 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md @@ -7,7 +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)). +- 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/dict_util.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/dict_util.py index 6f39474edf..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], @@ -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..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 @@ -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,19 +350,25 @@ 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 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], 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) @@ -393,39 +377,9 @@ 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__) - 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. @@ -488,11 +442,17 @@ def _maybe_update_error_type(self, response: GenerateContentResponse): def _maybe_log_completion_details( self, + request_attributes: dict[str, Any], + final_attributes: dict[str, Any], request: Union[ContentListUnion, ContentListUnionDict], candidates: list[Candidate], config: Optional[GenerateContentConfigOrDict] = None, ): - attributes = _get_gen_ai_request_attributes(config) + if ( + self.sem_conv_opt_in_mode + != _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL + ): + return system_instructions = [] if system_content := _config_to_system_instruction(config): system_instructions = to_system_instructions( @@ -506,7 +466,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 | final_attributes, ) self.completion_hook.on_completion( inputs=input_messages, @@ -540,7 +500,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 +708,7 @@ def instrumented_generate_content( config: Optional[GenerateContentConfigOrDict] = None, **kwargs: Any, ) -> GenerateContentResponse: + candidates = [] helper = _GenerateContentInstrumentationHelper( self, otel_wrapper, @@ -755,12 +716,21 @@ 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) + helper.process_request(contents, config, span) try: response = wrapped_func( self, @@ -769,23 +739,29 @@ 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 - ): - helper.process_completion(contents, response, config) + 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: - helper.finalize_processing() + final_attributes = helper.create_final_attributes() + span.set_attributes(final_attributes) + helper._maybe_log_completion_details( + request_attributes, + final_attributes, + contents, + candidates, + config, + ) + helper._record_token_usage_metric() + helper._record_duration_metric() return instrumented_generate_content @@ -815,12 +791,21 @@ 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) + helper.process_request(contents, config, span) try: for response in wrapped_func( self, @@ -829,28 +814,29 @@ 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 ( - 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( - contents, candidates, config + request_attributes, + final_attributes, + contents, + candidates, + config, ) - helper.finalize_processing() + helper._record_token_usage_metric() + helper._record_duration_metric() return instrumented_generate_content_stream @@ -879,12 +865,22 @@ 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) + helper.process_request(contents, config, span) try: response = await wrapped_func( self, @@ -893,23 +889,28 @@ 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 - ): - helper.process_completion(contents, response, config) + 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: - helper.finalize_processing() + final_attributes = helper.create_final_attributes() + span.set_attributes(final_attributes) + helper._maybe_log_completion_details( + request_attributes, + final_attributes, + contents, + candidates, + config, + ) + helper._record_token_usage_metric() + helper._record_duration_metric() return instrumented_generate_content @@ -939,14 +940,23 @@ 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) - if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT: - helper.process_request(contents, config) + span.set_attributes(request_attributes) + if not is_experimental_mode: + helper.process_request(contents, config, span) try: response_async_generator = await wrapped_func( self, @@ -957,7 +967,17 @@ 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, + contents, + [], + config, + ) + helper._record_duration_metric() with trace.use_span(span, end_on_exit=True): raise @@ -966,31 +986,29 @@ 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( - contents, candidates, config + request_attributes, + final_attributes, + contents, + candidates, + config, ) - helper.finalize_processing() + helper._record_token_usage_metric() + helper._record_duration_metric() return _response_async_generator_wrapper() @@ -1007,6 +1025,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 not in ( + _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL, + _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..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 @@ -94,16 +94,19 @@ 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(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() - self._generate_content_stream_mock = self._create_stream_mock() + 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): + def _create_nonstream_mock(self, e=None): mock = unittest.mock.MagicMock() def _default_impl(*args, **kwargs): @@ -114,17 +117,20 @@ def _default_impl(*args, **kwargs): self._response_index += 1 return result - mock.side_effect = _default_impl + mock.side_effect = e or _default_impl 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 4ae6f00063..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,7 +16,9 @@ import unittest from unittest.mock import patch +import pytest from google.genai.types import GenerateContentConfig +from pydantic import BaseModel, Field from opentelemetry._events import Event from opentelemetry.instrumentation._semconv import ( @@ -31,6 +33,12 @@ from .base import TestCase +# pylint: disable=too-many-public-methods + + +class ExampleResponseSchema(BaseModel): + name: str = Field(description="A Destination's Name") + class NonStreamingTestCase(TestCase): # The "setUp" function is defined by "unittest.TestCase" and thus @@ -92,6 +100,40 @@ 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() + with pytest.raises(ValueError): + self.generate_content( + model="gemini-2.0-flash", contents="Does this work?" + ) + 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( @@ -215,6 +257,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 +291,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 +301,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 +401,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/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() 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