diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/10.changed b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/10.changed new file mode 100644 index 00000000..c1d467a2 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/10.changed @@ -0,0 +1 @@ +Refactor code to make use of the shared GenAi Utils package `opentelemetry-util-genai`. This shared package is used by multiple GenAI instrumentations, and ensures sem convs are followed and up to date. This does result in some span attributes on the `execute_tool` span being removed (`code.function.parameters.someparam.type`, `code.function.parameters.someparam.value` etc.), and other sem conv compliant attributes being added to the span (specifically: `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result`), it also correctly changes the `SpanKind` from `INTERNAL` to `CLIENT`. The `generate_content` span also is switched to `SpanKind` `CLIENT`, and the `gen_ai.provider.name` attribute which was missing has been added, it's value is `vertex_ai`. The `InstrumentationScope` of the log and trace will also change, as the `TelemetryHandler` class in the utils package is now used to write the logs and traces. \ No newline at end of file diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py index 5d0541b6..260e1762 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py @@ -1,9 +1,7 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 -# pylint: disable=too-many-lines import copy -import dataclasses import functools import json import logging @@ -32,7 +30,6 @@ from opentelemetry import context as context_api from opentelemetry import trace -from opentelemetry._logs import LogRecord from opentelemetry.instrumentation._semconv import ( _OpenTelemetrySemanticConventionStability, _OpenTelemetryStabilitySignalType, @@ -44,17 +41,15 @@ ) from opentelemetry.semconv.attributes import error_attributes from opentelemetry.trace.span import Span -from opentelemetry.util.genai.completion_hook import CompletionHook +from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.invocation import ( + InferenceInvocation, +) from opentelemetry.util.genai.types import ( - ContentCapturingMode, FunctionToolDefinition, GenericToolDefinition, - InputMessage, - MessagePart, - OutputMessage, ToolDefinition, ) -from opentelemetry.util.genai.utils import gen_ai_json_dumps from opentelemetry.util.types import AttributeValue from .allowlist_util import AllowList @@ -67,7 +62,7 @@ to_system_instructions, ) from .otel_wrapper import OTelWrapper -from .tool_call_wrapper import wrapped as wrapped_tool +from .tool_call_wrapper import wrapped_tool _is_mcp_imported = False if typing.TYPE_CHECKING: @@ -327,26 +322,6 @@ async def _to_tool_definition_async( return _to_tool_definition_common(tool) -def _tool_def_without_parameters_attr( - tool_def: list[ToolDefinition], -) -> dict[str, AttributeValue]: - if tool_def == []: - return {} - - return { - GEN_AI_TOOL_DEFINITIONS: [ - dataclasses.asdict( - FunctionToolDefinition( - name=td.name, description=td.description, parameters=None - ) - if isinstance(td, FunctionToolDefinition) - else td - ) - for td in tool_def - ] - } - - def _create_request_attributes( config: Optional[GenerateContentConfigOrDict], allow_list: AllowList, @@ -423,15 +398,14 @@ def _coerce_config_to_object( def _wrapped_config_with_tools( - otel_wrapper: OTelWrapper, + telemetry_handler: TelemetryHandler, config: GenerateContentConfig, - **kwargs, ): if not config.tools: return config result = copy.copy(config) result.tools = [ - wrapped_tool(tool, otel_wrapper, **kwargs) for tool in config.tools + wrapped_tool(tool, telemetry_handler) for tool in config.tools ] return result @@ -458,36 +432,6 @@ def _config_to_tools( return config.tools -def _create_completion_details_attributes( - input_messages: list[InputMessage], - output_messages: list[OutputMessage], - system_instructions: list[MessagePart], - tool_definitions: list[ToolDefinition], - as_str: bool = False, -) -> dict[str, AttributeValue]: - attributes: dict[str, AttributeValue] = { - gen_ai_attributes.GEN_AI_INPUT_MESSAGES: [ - dataclasses.asdict(input_message) - for input_message in input_messages - ], - gen_ai_attributes.GEN_AI_OUTPUT_MESSAGES: [ - dataclasses.asdict(output_message) - for output_message in output_messages - ], - } - if system_instructions: - attributes[gen_ai_attributes.GEN_AI_SYSTEM_INSTRUCTIONS] = [ - dataclasses.asdict(sys_instr) for sys_instr in system_instructions - ] - - if tool_definitions: - attributes[GEN_AI_TOOL_DEFINITIONS] = [ - dataclasses.asdict(tool_def) for tool_def in tool_definitions - ] - - return attributes - - def _get_extra_generate_content_attributes() -> dict[str, AttributeValue]: attrs = context_api.get_value( GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY @@ -500,16 +444,16 @@ def __init__( self, models_object: Union[Models, AsyncModels], otel_wrapper: OTelWrapper, + telemetry_handler: TelemetryHandler, model: str, - completion_hook: CompletionHook, generate_content_config_key_allowlist: Optional[AllowList] = None, is_async: bool = False, ): self._start_time = time.time_ns() + self._telemetry_handler = telemetry_handler self._otel_wrapper = otel_wrapper self._genai_system = _determine_genai_system(models_object) self._genai_request_model = model - self.completion_hook = completion_hook self._finish_reasons_set = set() self._error_type = None self._input_tokens = 0 @@ -537,15 +481,31 @@ def __init__( ) self._is_async = is_async + def apply_finish_attributes( + self, + invocation: InferenceInvocation, + candidates: list[Candidate], + ): + invocation.input_tokens = self._input_tokens + invocation.output_tokens = self._output_tokens + invocation.finish_reasons = sorted(self._finish_reasons_set) + invocation.cache_read_input_tokens = self._cached_tokens + invocation.attributes["gen_ai.usage.reasoning.output_tokens"] = ( + self._thinking_tokens + ) + if self._content_recording_enabled and candidates: + invocation.output_messages = to_output_messages( + candidates=candidates + ) + def wrapped_config( self, config: Optional[GenerateContentConfigOrDict] ) -> Optional[GenerateContentConfig]: if config is None: return None return _wrapped_config_with_tools( - self._otel_wrapper, + self._telemetry_handler, _coerce_config_to_object(config), - extra_span_attributes={"gen_ai.system": self._genai_system}, ) def start_span_as_current_span( @@ -692,125 +652,6 @@ async def _maybe_get_tool_definitions_async( return tool_definitions - def _maybe_log_completion_details_in_log( - self, - event: LogRecord, - completion_details_attributes: dict[str, AttributeValue], - tool_definitions: Optional[list[ToolDefinition]] = None, - ): - if self._content_recording_enabled in [ - ContentCapturingMode.EVENT_ONLY, - ContentCapturingMode.SPAN_AND_EVENT, - ]: - event.attributes = { - **(event.attributes or {}), - **completion_details_attributes, - } - else: - event.attributes = { - **(event.attributes or {}), - **_tool_def_without_parameters_attr(tool_definitions), - } - - self._otel_wrapper.log_completion_details(event=event) - - def _maybe_log_completion_details_in_span( - self, - span: Span, - completion_details_attributes: dict[str, AttributeValue], - tool_definitions: Optional[list[ToolDefinition]] = None, - ): - if self._content_recording_enabled in [ - ContentCapturingMode.SPAN_ONLY, - ContentCapturingMode.SPAN_AND_EVENT, - ]: - span.set_attributes( - { - k: gen_ai_json_dumps(v) - for k, v in completion_details_attributes.items() - } - ) - # request attributes were already set on the span.. - else: - span.set_attributes( - { - k: gen_ai_json_dumps(v) - for k, v in _tool_def_without_parameters_attr( - tool_definitions - ).items() - } - ) - - def _maybe_log_completion_details( - self, - extra_attributes: dict[str, AttributeValue], - request_attributes: dict[str, AttributeValue], - final_attributes: dict[str, AttributeValue], - request: Union[ContentListUnion, ContentListUnionDict], - candidates: list[Candidate], - config: Optional[GenerateContentConfigOrDict] = None, - tool_definitions: Optional[list[ToolDefinition]] = None, - ): - if not self.experimental_sem_convs_enabled: - return - system_instructions = [] - if system_content := _config_to_system_instruction(config): - system_instructions = to_system_instructions( - content=transformers.t_contents(system_content)[0] - ) - input_messages = to_input_messages( - contents=transformers.t_contents(request) - ) - output_messages = to_output_messages(candidates=candidates) - span = trace.get_current_span() - event = LogRecord( - event_name="gen_ai.client.inference.operation.details", - attributes=extra_attributes - | request_attributes - | final_attributes, - ) - # New sem conv only gets added here when we've verified that experimental mode is set. - span.set_attribute( - gen_ai_attributes.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, - self._cached_tokens, - ) - event.attributes[ - gen_ai_attributes.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS - ] = self._cached_tokens - # TODO: replace these strings with the sem conv constant in `gen_ai_attributes` once it becomes available. - span.set_attribute( - "gen_ai.usage.reasoning.output_tokens", - self._thinking_tokens, - ) - event.attributes["gen_ai.usage.reasoning.output_tokens"] = ( - self._thinking_tokens - ) - tool_definitions = tool_definitions or [] - self.completion_hook.on_completion( - inputs=input_messages, - outputs=output_messages, - system_instruction=system_instructions, - tool_definitions=tool_definitions, - span=span, - log_record=event, - ) - completion_details_attributes = _create_completion_details_attributes( - input_messages, - output_messages, - system_instructions, - tool_definitions, - ) - self._maybe_log_completion_details_in_log( - event=event, - completion_details_attributes=completion_details_attributes, - tool_definitions=tool_definitions, - ) - self._maybe_log_completion_details_in_span( - span=span, - completion_details_attributes=completion_details_attributes, - tool_definitions=tool_definitions, - ) - def _maybe_log_system_instruction( self, config: Optional[GenerateContentConfigOrDict] = None ): @@ -1001,7 +842,7 @@ def _record_duration_metric(self): def _create_instrumented_generate_content( snapshot: _MethodsSnapshot, otel_wrapper: OTelWrapper, - completion_hook: CompletionHook, + telemetry_handler: TelemetryHandler, generate_content_config_key_allowlist: Optional[AllowList] = None, ): wrapped_func = snapshot.generate_content @@ -1015,62 +856,78 @@ def instrumented_generate_content( config: Optional[GenerateContentConfigOrDict] = None, **kwargs: Any, ) -> GenerateContentResponse: - candidates = [] helper = _GenerateContentInstrumentationHelper( self, otel_wrapper, + telemetry_handler, model, - completion_hook, generate_content_config_key_allowlist=generate_content_config_key_allowlist, is_async=False, ) - request_attributes = _create_request_attributes( - config, - helper._generate_content_config_key_allowlist, + extra_attributes = ( + _get_extra_generate_content_attributes() + | _create_request_attributes( + config, + helper._generate_content_config_key_allowlist, + ) ) - with helper.start_span_as_current_span( - model, "google.genai.Models.generate_content" - ) as span: - extra_attributes = _get_extra_generate_content_attributes() - span.set_attributes(extra_attributes | request_attributes) - if not helper.experimental_sem_convs_enabled: - helper.process_request(contents, config, span) - try: - response = wrapped_func( - self, - model=model, - contents=contents, - config=helper.wrapped_config(config), - **kwargs, + if helper.experimental_sem_convs_enabled: + with telemetry_handler.inference( + provider=helper._genai_system, + request_model=model, + operation_name="generate_content", + ) as invocation: + invocation.attributes.update(extra_attributes) + invocation.tool_definitions = ( + helper._maybe_get_tool_definitions(config) ) - if helper.experimental_sem_convs_enabled: - helper._update_response(response) - if response.candidates: - candidates += response.candidates - else: + if helper._content_recording_enabled: + invocation.input_messages = to_input_messages( + contents=transformers.t_contents(contents) + ) + if system_content := _config_to_system_instruction(config): + invocation.system_instruction = to_system_instructions( + content=transformers.t_contents(system_content)[0] + ) + candidates = [] + try: + response = wrapped_func( + self, + model=model, + contents=contents, + config=helper.wrapped_config(config), + **kwargs, + ) + if response.candidates: + candidates.extend(response.candidates) + helper._update_response(response) + return response + finally: + helper.apply_finish_attributes(invocation, candidates) + else: + with helper.start_span_as_current_span( + model, "google.genai.Models.generate_content" + ) as span: + span.set_attributes(extra_attributes) + helper.process_request(contents, config, span) + try: + response = wrapped_func( + self, + model=model, + contents=contents, + config=helper.wrapped_config(config), + **kwargs, + ) 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) - maybe_tool_definitions = helper._maybe_get_tool_definitions( - config - ) - helper._maybe_log_completion_details( - extra_attributes, - request_attributes, - final_attributes, - contents, - candidates, - config, - maybe_tool_definitions, - ) - helper._record_token_usage_metric() - helper._record_duration_metric() + return response + except Exception as error: + helper.process_error(error) + raise + finally: + span.set_attributes(helper.create_final_attributes()) + helper._record_token_usage_metric() + helper._record_duration_metric() return instrumented_generate_content @@ -1078,7 +935,7 @@ def instrumented_generate_content( def _create_instrumented_generate_content_stream( snapshot: _MethodsSnapshot, otel_wrapper: OTelWrapper, - completion_hook: CompletionHook, + telemetry_handler: TelemetryHandler, generate_content_config_key_allowlist: Optional[AllowList] = None, ): wrapped_func = snapshot.generate_content_stream @@ -1092,62 +949,77 @@ def instrumented_generate_content_stream( config: Optional[GenerateContentConfigOrDict] = None, **kwargs: Any, ) -> Iterator[GenerateContentResponse]: - candidates: list[Candidate] = [] helper = _GenerateContentInstrumentationHelper( self, otel_wrapper, + telemetry_handler, model, - completion_hook, generate_content_config_key_allowlist=generate_content_config_key_allowlist, is_async=False, ) - request_attributes = _create_request_attributes( - config, - helper._generate_content_config_key_allowlist, + extra_attributes = ( + _get_extra_generate_content_attributes() + | _create_request_attributes( + config, + helper._generate_content_config_key_allowlist, + ) ) - with helper.start_span_as_current_span( - model, "google.genai.Models.generate_content_stream" - ) as span: - extra_attributes = _get_extra_generate_content_attributes() - span.set_attributes(extra_attributes | request_attributes) - if not helper.experimental_sem_convs_enabled: + if helper.experimental_sem_convs_enabled: + with telemetry_handler.inference( + provider=helper._genai_system, + request_model=model, + operation_name="generate_content", + ) as invocation: + invocation.attributes.update(extra_attributes) + tool_defs = helper._maybe_get_tool_definitions(config) + invocation.tool_definitions = tool_defs + + if helper._content_recording_enabled: + invocation.input_messages = to_input_messages( + contents=transformers.t_contents(contents) + ) + if system_content := _config_to_system_instruction(config): + invocation.system_instruction = to_system_instructions( + content=transformers.t_contents(system_content)[0] + ) + candidates = [] + try: + for resp in wrapped_func( + self, + model=model, + contents=contents, + config=helper.wrapped_config(config), + **kwargs, + ): + helper._update_response(resp) + if resp.candidates: + candidates += resp.candidates + yield resp + finally: + helper.apply_finish_attributes(invocation, candidates) + else: + with helper.start_span_as_current_span( + model, "google.genai.Models.generate_content_stream" + ) as span: + span.set_attributes(extra_attributes) helper.process_request(contents, config, span) - try: - for response in wrapped_func( - self, - model=model, - contents=contents, - config=helper.wrapped_config(config), - **kwargs, - ): - if helper.experimental_sem_convs_enabled: - helper._update_response(response) - if response.candidates: - candidates += response.candidates - - else: + try: + for response in wrapped_func( + self, + model=model, + contents=contents, + config=helper.wrapped_config(config), + **kwargs, + ): 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) - maybe_tool_definitions = helper._maybe_get_tool_definitions( - config - ) - helper._maybe_log_completion_details( - extra_attributes, - request_attributes, - final_attributes, - contents, - candidates, - config, - maybe_tool_definitions, - ) - helper._record_token_usage_metric() - helper._record_duration_metric() + yield response + except Exception as error: + helper.process_error(error) + raise + finally: + span.set_attributes(helper.create_final_attributes()) + helper._record_token_usage_metric() + helper._record_duration_metric() return instrumented_generate_content_stream @@ -1155,7 +1027,7 @@ def instrumented_generate_content_stream( def _create_instrumented_async_generate_content( snapshot: _MethodsSnapshot, otel_wrapper: OTelWrapper, - completion_hook: CompletionHook, + telemetry_handler: TelemetryHandler, generate_content_config_key_allowlist: Optional[AllowList] = None, ): wrapped_func = snapshot.async_generate_content @@ -1172,58 +1044,76 @@ async def instrumented_generate_content( helper = _GenerateContentInstrumentationHelper( self, otel_wrapper, + telemetry_handler, model, - completion_hook, generate_content_config_key_allowlist=generate_content_config_key_allowlist, is_async=True, ) - request_attributes = _create_request_attributes( - config, - helper._generate_content_config_key_allowlist, + extra_attributes = ( + _get_extra_generate_content_attributes() + | _create_request_attributes( + config, + helper._generate_content_config_key_allowlist, + ) ) - candidates: list[Candidate] = [] - with helper.start_span_as_current_span( - model, "google.genai.AsyncModels.generate_content" - ) as span: - extra_attributes = _get_extra_generate_content_attributes() - span.set_attributes(extra_attributes | request_attributes) - if not helper.experimental_sem_convs_enabled: - helper.process_request(contents, config, span) - try: - response = await wrapped_func( - self, - model=model, - contents=contents, - config=helper.wrapped_config(config), - **kwargs, + if helper.experimental_sem_convs_enabled: + with telemetry_handler.inference( + provider=helper._genai_system, + request_model=model, + operation_name="generate_content", + ) as invocation: + invocation.attributes.update(extra_attributes) + invocation.tool_definitions = ( + await helper._maybe_get_tool_definitions_async(config) ) - if helper.experimental_sem_convs_enabled: + + if helper._content_recording_enabled: + invocation.input_messages = to_input_messages( + contents=transformers.t_contents(contents) + ) + if system_content := _config_to_system_instruction(config): + invocation.system_instruction = to_system_instructions( + content=transformers.t_contents(system_content)[0] + ) + candidates = [] + try: + response = await wrapped_func( + self, + model=model, + contents=contents, + config=helper.wrapped_config(config), + **kwargs, + ) helper._update_response(response) if response.candidates: candidates += response.candidates - else: + return response + finally: + helper.apply_finish_attributes(invocation, candidates) + + else: + with helper.start_span_as_current_span( + model, "google.genai.AsyncModels.generate_content" + ) as span: + span.set_attributes(extra_attributes) + helper.process_request(contents, config, span) + try: + response = await wrapped_func( + self, + model=model, + contents=contents, + config=helper.wrapped_config(config), + **kwargs, + ) 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) - maybe_tool_definitions = ( - await helper._maybe_get_tool_definitions_async(config) - ) - helper._maybe_log_completion_details( - extra_attributes, - request_attributes, - final_attributes, - contents, - candidates, - config, - maybe_tool_definitions, - ) - helper._record_token_usage_metric() - helper._record_duration_metric() + return response + except Exception as error: + helper.process_error(error) + raise + finally: + span.set_attributes(helper.create_final_attributes()) + helper._record_token_usage_metric() + helper._record_duration_metric() return instrumented_generate_content @@ -1232,7 +1122,7 @@ async def instrumented_generate_content( def _create_instrumented_async_generate_content_stream( # type: ignore snapshot: _MethodsSnapshot, otel_wrapper: OTelWrapper, - completion_hook: CompletionHook, + telemetry_handler: TelemetryHandler, generate_content_config_key_allowlist: Optional[AllowList] = None, ): wrapped_func = snapshot.async_generate_content_stream @@ -1249,90 +1139,99 @@ async def instrumented_generate_content_stream( helper = _GenerateContentInstrumentationHelper( self, otel_wrapper, + telemetry_handler, model, - completion_hook, generate_content_config_key_allowlist=generate_content_config_key_allowlist, is_async=True, ) - request_attributes = _create_request_attributes( - config, - helper._generate_content_config_key_allowlist, + extra_attributes = ( + _get_extra_generate_content_attributes() + | _create_request_attributes( + config, + 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: - extra_attributes = _get_extra_generate_content_attributes() - span.set_attributes(extra_attributes | request_attributes) - if not helper.experimental_sem_convs_enabled: - helper.process_request(contents, config, span) - try: - response_async_generator = await wrapped_func( - self, - model=model, - contents=contents, - config=helper.wrapped_config(config), - **kwargs, - ) - except Exception as error: # pylint: disable=broad-exception-caught - helper.process_error(error) - helper._record_token_usage_metric() - final_attributes = helper.create_final_attributes() - span.set_attributes(final_attributes) - maybe_tool_definitions = ( - await helper._maybe_get_tool_definitions_async(config) - ) - helper._maybe_log_completion_details( - extra_attributes, - request_attributes, - final_attributes, - contents, - [], - config, - maybe_tool_definitions, + if helper.experimental_sem_convs_enabled: + invocation = telemetry_handler.start_inference( + provider=helper._genai_system, + request_model=model, + operation_name="generate_content", + ) + invocation.attributes.update(extra_attributes) + invocation.tool_definitions = ( + await helper._maybe_get_tool_definitions_async(config) + ) + invocation.input_messages = to_input_messages( + contents=transformers.t_contents(contents) + ) + if system_content := _config_to_system_instruction(config): + invocation.system_instruction = to_system_instructions( + content=transformers.t_contents(system_content)[0] ) - helper._record_duration_metric() - with trace.use_span(span, end_on_exit=True): - raise async def _response_async_generator_wrapper(): - candidates: list[Candidate] = [] - with trace.use_span(span, end_on_exit=True): - try: - async for response in response_async_generator: - if helper.experimental_sem_convs_enabled: - helper._update_response(response) - if response.candidates: - candidates += response.candidates - - else: - helper.process_response(response) - yield response - except Exception as error: - helper.process_error(error) + candidates = [] + try: + async for resp in await wrapped_func( + self, + model=model, + contents=contents, + config=helper.wrapped_config(config), + **kwargs, + ): + helper._update_response(resp) + if resp.candidates: + candidates += resp.candidates + yield resp + helper.apply_finish_attributes(invocation, candidates) + invocation.stop() + except Exception as exc: + helper.apply_finish_attributes(invocation, candidates) + invocation.fail(exc) + raise + + return _response_async_generator_wrapper() + else: + with helper.start_span_as_current_span( + model, + "google.genai.AsyncModels.generate_content_stream", + end_on_exit=False, + ) as span: + span.set_attributes(extra_attributes) + helper.process_request(contents, config, span) + try: + response_async_generator = await wrapped_func( + self, + model=model, + contents=contents, + config=helper.wrapped_config(config), + **kwargs, + ) + except Exception as error: # pylint: disable=broad-exception-caught + helper.process_error(error) + helper._record_token_usage_metric() + span.set_attributes(helper.create_final_attributes()) + helper._record_duration_metric() + with trace.use_span(span, end_on_exit=True): raise - finally: - final_attributes = helper.create_final_attributes() - span.set_attributes(final_attributes) - maybe_tool_definitions = ( - await helper._maybe_get_tool_definitions_async( - config + + async def _response_async_generator_wrapper(): + with trace.use_span(span, end_on_exit=True): + try: + async for response in response_async_generator: + helper.process_response(response) + yield response + except Exception as error: + helper.process_error(error) + raise + finally: + span.set_attributes( + helper.create_final_attributes() ) - ) - helper._maybe_log_completion_details( - extra_attributes, - request_attributes, - final_attributes, - contents, - candidates, - config, - maybe_tool_definitions, - ) - helper._record_token_usage_metric() - helper._record_duration_metric() + helper._record_token_usage_metric() + helper._record_duration_metric() - return _response_async_generator_wrapper() + return _response_async_generator_wrapper() return instrumented_generate_content_stream @@ -1344,7 +1243,7 @@ def uninstrument_generate_content(snapshot: object): def instrument_generate_content( otel_wrapper: OTelWrapper, - completion_hook: CompletionHook, + telemetry_handler: TelemetryHandler, generate_content_config_key_allowlist: Optional[AllowList] = None, ) -> object: opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( @@ -1355,29 +1254,31 @@ def instrument_generate_content( _StabilityMode.DEFAULT, ): raise ValueError(f"Sem Conv opt in mode {opt_in_mode} not supported.") + if opt_in_mode == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL: + os.environ["OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT"] = "true" snapshot = _MethodsSnapshot() Models.generate_content = _create_instrumented_generate_content( snapshot, otel_wrapper, - completion_hook, + telemetry_handler, generate_content_config_key_allowlist=generate_content_config_key_allowlist, ) Models.generate_content_stream = _create_instrumented_generate_content_stream( snapshot, otel_wrapper, - completion_hook, + telemetry_handler, generate_content_config_key_allowlist=generate_content_config_key_allowlist, ) AsyncModels.generate_content = _create_instrumented_async_generate_content( snapshot, otel_wrapper, - completion_hook, + telemetry_handler, generate_content_config_key_allowlist=generate_content_config_key_allowlist, ) AsyncModels.generate_content_stream = _create_instrumented_async_generate_content_stream( snapshot, otel_wrapper, - completion_hook, + telemetry_handler, generate_content_config_key_allowlist=generate_content_config_key_allowlist, ) return snapshot diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/instrumentor.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/instrumentor.py index cc8c1aaa..b95227c8 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/instrumentor.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/instrumentor.py @@ -8,6 +8,7 @@ from opentelemetry.metrics import get_meter_provider from opentelemetry.trace import get_tracer_provider from opentelemetry.util.genai.completion_hook import load_completion_hook +from opentelemetry.util.genai.handler import TelemetryHandler from .allowlist_util import AllowList from .generate_content import ( @@ -51,9 +52,15 @@ def _instrument(self, **kwargs: Any): completion_hook = ( kwargs.get("completion_hook") or load_completion_hook() ) + telemetry_handler = TelemetryHandler( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + logger_provider=logger_provider, + completion_hook=completion_hook, + ) self._generate_content_snapshot = instrument_generate_content( otel_wrapper, - completion_hook, + telemetry_handler, generate_content_config_key_allowlist=self._generate_content_config_key_allowlist, ) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py index 10d07404..668e3dd1 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py @@ -12,19 +12,7 @@ ToolOrDict, ) -from opentelemetry import trace -from opentelemetry.instrumentation._semconv import ( - _OpenTelemetrySemanticConventionStability, - _OpenTelemetryStabilitySignalType, - _StabilityMode, -) -from opentelemetry.semconv._incubating.attributes import ( - code_attributes, -) -from opentelemetry.util.genai.types import ContentCapturingMode - -from .flags import is_content_recording_enabled -from .otel_wrapper import OTelWrapper +from opentelemetry.util.genai.handler import TelemetryHandler ToolFunction = Callable[..., Any] @@ -50,180 +38,93 @@ def _to_otel_value(python_value): return repr(python_value) -def _is_homogenous_primitive_list(value): - if not isinstance(value, list): - return False - if not value: - return True - if not _is_primitive(value[0]): - return False - first_type = type(value[0]) - for entry in value[1:]: - if not isinstance(entry, first_type): - return False - return True - - -def _to_otel_attribute(python_value): - otel_value = _to_otel_value(python_value) - if _is_primitive(otel_value) or _is_homogenous_primitive_list(otel_value): - return otel_value - return json.dumps(otel_value) - - -def _is_capture_content_enabled() -> bool: - if ( - _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( - _OpenTelemetryStabilitySignalType.GEN_AI - ) - == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL - ): - return is_content_recording_enabled(True) in [ - ContentCapturingMode.SPAN_ONLY, - ContentCapturingMode.SPAN_AND_EVENT, - ] - return bool(is_content_recording_enabled(False)) - - -def _create_function_span_name(wrapped_function): - """Constructs the span name for a given local function tool call.""" - function_name = wrapped_function.__name__ - return f"execute_tool {function_name}" - - -def _create_function_span_attributes( - wrapped_function, function_args, function_kwargs, extra_span_attributes -): - """Creates the attributes for a tool call function span.""" - result = {} - if extra_span_attributes: - result.update(extra_span_attributes) - result["gen_ai.operation.name"] = "execute_tool" - result["gen_ai.tool.name"] = wrapped_function.__name__ - if wrapped_function.__doc__: - result["gen_ai.tool.description"] = wrapped_function.__doc__ - result[code_attributes.CODE_FUNCTION_NAME] = wrapped_function.__name__ - result["code.module"] = wrapped_function.__module__ - result["code.args.positional.count"] = len(function_args) - result["code.args.keyword.count"] = len(function_kwargs) - return result - - -def _record_function_call_argument( - span, param_name, param_value, include_values -): - attribute_prefix = f"code.function.parameters.{param_name}" - type_attribute = f"{attribute_prefix}.type" - span.set_attribute(type_attribute, type(param_value).__name__) - if include_values: - value_attribute = f"{attribute_prefix}.value" - span.set_attribute(value_attribute, _to_otel_attribute(param_value)) - - -def _record_function_call_arguments( - otel_wrapper, wrapped_function, function_args, function_kwargs -): +# There is non canonical way to serialize a Python object to a span attribute value. +# Span attribute values currently most be one of the primitive types, or a homogeneous list of primitive types. +# In the future the value will be expanded to include None, a heterogeneous lists of primitive types, and a Map of these types. +# See https://github.com/open-telemetry/opentelemetry-specification/pull/4485 +def _get_function_args(wrapped_function, function_args, function_kwargs): """Records the details about a function invocation as span attributes.""" - include_values = _is_capture_content_enabled() - span = trace.get_current_span() + function_arg_attr = {} signature = inspect.signature(wrapped_function) params = list(signature.parameters.values()) for index, entry in enumerate(function_args): param_name = f"args[{index}]" if index < len(params): param_name = params[index].name - _record_function_call_argument(span, param_name, entry, include_values) + function_arg_attr[f"code.function.parameters.{param_name}.type"] = ( + type(entry).__name__ + ) + function_arg_attr[f"code.function.parameters.{param_name}.value"] = ( + _to_otel_value(entry) + ) for key, value in function_kwargs.items(): - _record_function_call_argument(span, key, value, include_values) - - -def _record_function_call_result(otel_wrapper, wrapped_function, result): - """Records the details about a function result as span attributes.""" - include_values = _is_capture_content_enabled() - span = trace.get_current_span() - span.set_attribute("code.function.return.type", type(result).__name__) - if include_values: - span.set_attribute( - "code.function.return.value", _to_otel_attribute(result) + function_arg_attr[f"code.function.parameters.{key}.type"] = type( + value + ).__name__ + function_arg_attr[f"code.function.parameters.{key}.value"] = ( + _to_otel_value(value) ) + return function_arg_attr -def _wrap_sync_tool_function( +def _wrap_tool_function( tool_function: ToolFunction, - otel_wrapper: OTelWrapper, - extra_span_attributes: Optional[dict[str, str]] = None, - **unused_kwargs, + telemetry_handler: TelemetryHandler, ): - @functools.wraps(tool_function) - def wrapped_function(*args, **kwargs): - span_name = _create_function_span_name(tool_function) - attributes = _create_function_span_attributes( - tool_function, args, kwargs, extra_span_attributes - ) - with otel_wrapper.start_as_current_span( - span_name, attributes=attributes - ): - _record_function_call_arguments( - otel_wrapper, tool_function, args, kwargs - ) - result = tool_function(*args, **kwargs) - _record_function_call_result(otel_wrapper, tool_function, result) - return result - - return wrapped_function - + if inspect.iscoroutinefunction(tool_function): -def _wrap_async_tool_function( - tool_function: ToolFunction, - otel_wrapper: OTelWrapper, - extra_span_attributes: Optional[dict[str, str]] = None, - **unused_kwargs, -): - @functools.wraps(tool_function) - async def wrapped_function(*args, **kwargs): - span_name = _create_function_span_name(tool_function) - attributes = _create_function_span_attributes( - tool_function, args, kwargs, extra_span_attributes - ) - with otel_wrapper.start_as_current_span( - span_name, attributes=attributes - ): - _record_function_call_arguments( - otel_wrapper, tool_function, args, kwargs - ) - result = await tool_function(*args, **kwargs) - _record_function_call_result(otel_wrapper, tool_function, result) + @functools.wraps(tool_function) + async def wrapped_function(*args, **kwargs): + with telemetry_handler.tool( + tool_function.__name__, tool_description=tool_function.__doc__ + ) as tool_invocation: + result = await tool_function(*args, **kwargs) + # Always json.dumps. First we convert args / result to something that we can serialize, then we serialize. + # The return value of _to_otel_value could be a dict, which currently cannot be a span attribute.. + # In the future that could change (see https://github.com/open-telemetry/opentelemetry-specification/pull/4485), and we could possibly stop using json.dumps here. + tool_invocation.arguments = json.dumps( + _get_function_args(tool_function, args, kwargs) + ) + tool_invocation.tool_result = json.dumps( + _to_otel_value(result) + ) + return result + else: + + @functools.wraps(tool_function) + def wrapped_function(*args, **kwargs): + with telemetry_handler.tool( + tool_function.__name__, tool_description=tool_function.__doc__ + ) as tool_invocation: + result = tool_function(*args, **kwargs) + tool_invocation.arguments = json.dumps( + _get_function_args(tool_function, args, kwargs) + ) + tool_invocation.tool_result = json.dumps( + _to_otel_value(result) + ) return result return wrapped_function -def _wrap_tool_function( - tool_function: ToolFunction, otel_wrapper: OTelWrapper, **kwargs -): - if inspect.iscoroutinefunction(tool_function): - return _wrap_async_tool_function(tool_function, otel_wrapper, **kwargs) - return _wrap_sync_tool_function(tool_function, otel_wrapper, **kwargs) - - -def wrapped( +def wrapped_tool( tool_or_tools: Optional[ Union[ToolFunction, ToolOrDict, ToolListUnion, ToolListUnionDict] ], - otel_wrapper: OTelWrapper, - **kwargs, + telemetry_handler: TelemetryHandler, ): if tool_or_tools is None: return None if isinstance(tool_or_tools, list): return [ - wrapped(item, otel_wrapper, **kwargs) for item in tool_or_tools + wrapped_tool(tool, telemetry_handler) for tool in tool_or_tools ] if isinstance(tool_or_tools, dict): return { - key: wrapped(value, otel_wrapper, **kwargs) - for (key, value) in tool_or_tools.items() + key: wrapped_tool(tool, telemetry_handler) + for (key, tool) in tool_or_tools.items() } if callable(tool_or_tools): - return _wrap_tool_function(tool_or_tools, otel_wrapper, **kwargs) + return _wrap_tool_function(tool_or_tools, telemetry_handler) return tool_or_tools diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py index 2d160af0..cf847518 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py @@ -805,6 +805,9 @@ def test_new_semconv_record_completion_in_span(self): span = self.otel.get_span_named( "generate_content gemini-2.0-flash" ) + self.assertEqual( + span.attributes["gen_ai.provider.name"], "gemini" + ) self.assertEqual( span.attributes[ "gen_ai.usage.cache_read.input_tokens" diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_tool_call_instrumentation.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_tool_call_instrumentation.py deleted file mode 100644 index ea81f1f1..00000000 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_tool_call_instrumentation.py +++ /dev/null @@ -1,431 +0,0 @@ -# Copyright The OpenTelemetry Authors -# SPDX-License-Identifier: Apache-2.0 - -from unittest.mock import patch - -import google.genai.types as genai_types - -from opentelemetry.instrumentation._semconv import ( - _OpenTelemetrySemanticConventionStability, - _OpenTelemetryStabilitySignalType, - _StabilityMode, -) -from opentelemetry.util.genai.types import ContentCapturingMode - -from .base import TestCase - - -class ToolCallInstrumentationTestCase(TestCase): - def test_tool_calls_with_config_dict_outputs_spans(self): - calls = [] - - def handle(*args, **kwargs): - calls.append((args, kwargs)) - return "some result" - - def somefunction(somearg): - print("somearg=%s", somearg) - - self.mock_generate_content.side_effect = handle - self.client.models.generate_content( - model="some-model-name", - contents="Some content", - config={ - "tools": [somefunction], - }, - ) - self.assertEqual(len(calls), 1) - config = calls[0][1]["config"] - tools = config.tools - wrapped_somefunction = tools[0] - - self.assertIsNone( - self.otel.get_span_named("execute_tool somefunction") - ) - wrapped_somefunction("someparam") - self.otel.assert_has_span_named("execute_tool somefunction") - generated_span = self.otel.get_span_named("execute_tool somefunction") - self.assertIn("gen_ai.system", generated_span.attributes) - self.assertEqual( - generated_span.attributes["gen_ai.tool.name"], "somefunction" - ) - self.assertEqual( - generated_span.attributes["code.args.positional.count"], 1 - ) - self.assertEqual( - generated_span.attributes["code.args.keyword.count"], 0 - ) - - def test_tool_calls_with_config_object_outputs_spans(self): - calls = [] - - def handle(*args, **kwargs): - calls.append((args, kwargs)) - return "some result" - - def somefunction(somearg): - print("somearg=%s", somearg) - - self.mock_generate_content.side_effect = handle - self.client.models.generate_content( - model="some-model-name", - contents="Some content", - config=genai_types.GenerateContentConfig( - tools=[somefunction], - ), - ) - self.assertEqual(len(calls), 1) - config = calls[0][1]["config"] - tools = config.tools - wrapped_somefunction = tools[0] - - self.assertIsNone( - self.otel.get_span_named("execute_tool somefunction") - ) - wrapped_somefunction("someparam") - self.otel.assert_has_span_named("execute_tool somefunction") - generated_span = self.otel.get_span_named("execute_tool somefunction") - self.assertIn("gen_ai.system", generated_span.attributes) - self.assertEqual( - generated_span.attributes["gen_ai.tool.name"], "somefunction" - ) - self.assertEqual( - generated_span.attributes["code.args.positional.count"], 1 - ) - self.assertEqual( - generated_span.attributes["code.args.keyword.count"], 0 - ) - - @patch.dict( - "os.environ", - {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, - ) - def test_tool_calls_record_parameter_values_on_span_if_enabled(self): - calls = [] - - def handle(*args, **kwargs): - calls.append((args, kwargs)) - return "some result" - - def somefunction(someparam, otherparam=2): - print("someparam=%s, otherparam=%s", someparam, otherparam) - - self.mock_generate_content.side_effect = handle - self.client.models.generate_content( - model="some-model-name", - contents="Some content", - config={ - "tools": [somefunction], - }, - ) - self.assertEqual(len(calls), 1) - config = calls[0][1]["config"] - tools = config.tools - wrapped_somefunction = tools[0] - wrapped_somefunction(123, otherparam="abc") - self.otel.assert_has_span_named("execute_tool somefunction") - generated_span = self.otel.get_span_named("execute_tool somefunction") - self.assertEqual( - generated_span.attributes[ - "code.function.parameters.someparam.type" - ], - "int", - ) - self.assertEqual( - generated_span.attributes[ - "code.function.parameters.otherparam.type" - ], - "str", - ) - self.assertEqual( - generated_span.attributes[ - "code.function.parameters.someparam.value" - ], - 123, - ) - self.assertEqual( - generated_span.attributes[ - "code.function.parameters.otherparam.value" - ], - "abc", - ) - - @patch.dict( - "os.environ", - {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "false"}, - ) - def test_tool_calls_do_not_record_parameter_values_if_not_enabled(self): - calls = [] - - def handle(*args, **kwargs): - calls.append((args, kwargs)) - return "some result" - - def somefunction(someparam, otherparam=2): - print("someparam=%s, otherparam=%s", someparam, otherparam) - - self.mock_generate_content.side_effect = handle - self.client.models.generate_content( - model="some-model-name", - contents="Some content", - config={ - "tools": [somefunction], - }, - ) - self.assertEqual(len(calls), 1) - config = calls[0][1]["config"] - tools = config.tools - wrapped_somefunction = tools[0] - wrapped_somefunction(123, otherparam="abc") - self.otel.assert_has_span_named("execute_tool somefunction") - generated_span = self.otel.get_span_named("execute_tool somefunction") - self.assertEqual( - generated_span.attributes[ - "code.function.parameters.someparam.type" - ], - "int", - ) - self.assertEqual( - generated_span.attributes[ - "code.function.parameters.otherparam.type" - ], - "str", - ) - self.assertNotIn( - "code.function.parameters.someparam.value", - generated_span.attributes, - ) - self.assertNotIn( - "code.function.parameters.otherparam.value", - generated_span.attributes, - ) - - @patch.dict( - "os.environ", - {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"}, - ) - def test_tool_calls_record_return_values_on_span_if_enabled(self): - calls = [] - - def handle(*args, **kwargs): - calls.append((args, kwargs)) - return "some result" - - def somefunction(x, y=2): - return x + y - - self.mock_generate_content.side_effect = handle - self.client.models.generate_content( - model="some-model-name", - contents="Some content", - config={ - "tools": [somefunction], - }, - ) - self.assertEqual(len(calls), 1) - config = calls[0][1]["config"] - tools = config.tools - wrapped_somefunction = tools[0] - wrapped_somefunction(123) - self.otel.assert_has_span_named("execute_tool somefunction") - generated_span = self.otel.get_span_named("execute_tool somefunction") - self.assertEqual( - generated_span.attributes["code.function.return.type"], "int" - ) - self.assertEqual( - generated_span.attributes["code.function.return.value"], 125 - ) - - @patch.dict( - "os.environ", - {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "false"}, - ) - def test_tool_calls_do_not_record_return_values_if_not_enabled(self): - calls = [] - - def handle(*args, **kwargs): - calls.append((args, kwargs)) - return "some result" - - def somefunction(x, y=2): - return x + y - - self.mock_generate_content.side_effect = handle - self.client.models.generate_content( - model="some-model-name", - contents="Some content", - config={ - "tools": [somefunction], - }, - ) - self.assertEqual(len(calls), 1) - config = calls[0][1]["config"] - tools = config.tools - wrapped_somefunction = tools[0] - wrapped_somefunction(123) - self.otel.assert_has_span_named("execute_tool somefunction") - generated_span = self.otel.get_span_named("execute_tool somefunction") - self.assertEqual( - generated_span.attributes["code.function.return.type"], "int" - ) - self.assertNotIn( - "code.function.return.value", generated_span.attributes - ) - - def test_new_semconv_tool_calls_record_parameter_values(self): - for mode in ContentCapturingMode: - calls = [] - 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 - ): - self.setUp() - with patched_environ, patched_otel_mapping: - - def handle(*args, **kwargs): - calls.append((args, kwargs)) # pylint: disable=cell-var-from-loop - return "some result" - - def somefunction(someparam, otherparam=2): - print( - "someparam=%s, otherparam=%s", - someparam, - otherparam, - ) - - self.mock_generate_content.side_effect = handle - self.client.models.generate_content( - model="some-model-name", - contents="Some content", - config={ - "tools": [somefunction], - }, - ) - self.assertEqual(len(calls), 1) - config = calls[0][1]["config"] - tools = config.tools - wrapped_somefunction = tools[0] - wrapped_somefunction(123, otherparam="abc") - self.otel.assert_has_span_named( - "execute_tool somefunction" - ) - generated_span = self.otel.get_span_named( - "execute_tool somefunction" - ) - self.assertEqual( - generated_span.attributes[ - "code.function.parameters.someparam.type" - ], - "int", - ) - self.assertEqual( - generated_span.attributes[ - "code.function.parameters.otherparam.type" - ], - "str", - ) - if mode in [ - ContentCapturingMode.SPAN_ONLY, - ContentCapturingMode.SPAN_AND_EVENT, - ]: - self.assertEqual( - generated_span.attributes[ - "code.function.parameters.someparam.value" - ], - 123, - ) - self.assertEqual( - generated_span.attributes[ - "code.function.parameters.otherparam.value" - ], - "abc", - ) - else: - self.assertNotIn( - "code.function.parameters.someparam.value", - generated_span.attributes, - ) - self.assertNotIn( - "code.function.parameters.otherparam.value", - generated_span.attributes, - ) - self.tearDown() - - def test_new_semconv_tool_calls_record_return_values(self): - for mode in ContentCapturingMode: - calls = [] - 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 - ): - self.setUp() - with patched_environ, patched_otel_mapping: - - def handle(*args, **kwargs): - calls.append((args, kwargs)) # pylint: disable=cell-var-from-loop - return "some result" - - def somefunction(x, y=2): - return x + y - - self.mock_generate_content.side_effect = handle - self.client.models.generate_content( - model="some-model-name", - contents="Some content", - config={ - "tools": [somefunction], - }, - ) - self.assertEqual(len(calls), 1) - config = calls[0][1]["config"] - tools = config.tools - wrapped_somefunction = tools[0] - wrapped_somefunction(123) - self.otel.assert_has_span_named( - "execute_tool somefunction" - ) - generated_span = self.otel.get_span_named( - "execute_tool somefunction" - ) - self.assertEqual( - generated_span.attributes["code.function.return.type"], - "int", - ) - if mode in [ - ContentCapturingMode.SPAN_ONLY, - ContentCapturingMode.SPAN_AND_EVENT, - ]: - self.assertIn( - "code.function.return.value", - generated_span.attributes, - ) - else: - self.assertNotIn( - "code.function.return.value", - generated_span.attributes, - ) - self.tearDown() diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt index d4a94c2a..80756744 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt @@ -21,10 +21,10 @@ opentelemetry-api==1.40.0 opentelemetry-sdk==1.40.0 opentelemetry-semantic-conventions==0.61b0 opentelemetry-instrumentation==0.61b0 -opentelemetry-util-genai[upload]==0.4b.0 fsspec==2025.9.0 # Install locally from the folder. This path is relative to the # root directory, given invocation from "tox" at root level. -e instrumentation/opentelemetry-instrumentation-google-genai +-e util/opentelemetry-util-genai diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py index a1dc485a..71dcdd74 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio -import os +import json import unittest from unittest.mock import patch @@ -12,13 +12,10 @@ from opentelemetry.instrumentation._semconv import ( _OpenTelemetrySemanticConventionStability, ) -from opentelemetry.instrumentation.google_genai import ( - otel_wrapper, - tool_call_wrapper, -) +from opentelemetry.instrumentation.google_genai import tool_call_wrapper from opentelemetry.metrics import get_meter_provider from opentelemetry.trace import get_tracer_provider -from opentelemetry.util.genai.types import ContentCapturingMode +from opentelemetry.util.genai.handler import TelemetryHandler from ..common import otel_mocker @@ -27,17 +24,11 @@ class TestCase(unittest.TestCase): def setUp(self): self._otel = otel_mocker.OTelMocker() self._otel.install() - self._otel_wrapper = otel_wrapper.OTelWrapper.from_providers( - get_tracer_provider(), - get_logger_provider(), - get_meter_provider(), - ) - os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( - "true" + self._otel_wrapper = TelemetryHandler( + tracer_provider=get_tracer_provider(), + logger_provider=get_logger_provider(), + meter_provider=get_meter_provider(), ) - os.environ["OTEL_SEMCONV_STABILITY_OPT_IN"] = "default" - _OpenTelemetrySemanticConventionStability._initialized = False - _OpenTelemetrySemanticConventionStability._initialize() @property def otel(self): @@ -47,31 +38,13 @@ def otel(self): def otel_wrapper(self): return self._otel_wrapper - def wrap(self, tool_or_tools, **kwargs): - return tool_call_wrapper.wrapped( - tool_or_tools, self.otel_wrapper, **kwargs - ) + def wrap(self, tool_or_tools): + return tool_call_wrapper.wrapped_tool(tool_or_tools, self.otel_wrapper) def test_wraps_none(self): result = self.wrap(None) self.assertIsNone(result) - def test_wraps_single_tool_function(self): - def somefunction(): - pass - - wrapped_somefunction = self.wrap(somefunction) - self.otel.assert_does_not_have_span_named("execute_tool somefunction") - somefunction() - self.otel.assert_does_not_have_span_named("execute_tool somefunction") - wrapped_somefunction() - self.otel.assert_has_span_named("execute_tool somefunction") - span = self.otel.get_span_named("execute_tool somefunction") - self.assertEqual( - span.attributes["gen_ai.operation.name"], "execute_tool" - ) - self.assertEqual(span.attributes["gen_ai.tool.name"], "somefunction") - def test_wraps_multiple_tool_functions_as_list(self): def somefunction(): pass @@ -163,138 +136,116 @@ def somefunction(): "An example tool call function.", ) - def test_handles_primitive_int_arg(self): - def somefunction(arg=None): + @patch.dict( + "os.environ", + { + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true", + "OTEL_SEMCONV_STABILITY_OPT_IN": "default", + }, + ) + def test_handles_various_arg_types(self): + def somefunction( + primitive_int=None, + dict_arg=None, + list_arg=None, + heterogenous_list_arg=None, + ): pass + _OpenTelemetrySemanticConventionStability._initialized = False + _OpenTelemetrySemanticConventionStability._initialize() wrapped_somefunction = self.wrap(somefunction) self.otel.assert_does_not_have_span_named("execute_tool somefunction") somefunction(12345) self.otel.assert_does_not_have_span_named("execute_tool somefunction") - wrapped_somefunction(12345) + wrapped_somefunction(12345, {"key": "value"}, [1, 2, 3], [123, "abc"]) self.otel.assert_has_span_named("execute_tool somefunction") span = self.otel.get_span_named("execute_tool somefunction") + arguments = json.loads(span.attributes["gen_ai.tool.call.arguments"]) self.assertEqual( - span.attributes["code.function.parameters.arg.type"], "int" + arguments["code.function.parameters.primitive_int.type"], "int" ) self.assertEqual( - span.attributes["code.function.parameters.arg.value"], 12345 + arguments["code.function.parameters.primitive_int.value"], 12345 ) - - def test_handles_primitive_string_arg(self): - def somefunction(arg=None): - pass - - wrapped_somefunction = self.wrap(somefunction) - self.otel.assert_does_not_have_span_named("execute_tool somefunction") - somefunction("a string value") - self.otel.assert_does_not_have_span_named("execute_tool somefunction") - wrapped_somefunction("a string value") - self.otel.assert_has_span_named("execute_tool somefunction") - span = self.otel.get_span_named("execute_tool somefunction") self.assertEqual( - span.attributes["code.function.parameters.arg.type"], "str" + arguments["code.function.parameters.dict_arg.type"], "dict" ) self.assertEqual( - span.attributes["code.function.parameters.arg.value"], - "a string value", + arguments["code.function.parameters.dict_arg.value"], + {"key": "value"}, ) - - def test_handles_dict_arg(self): - def somefunction(arg=None): - pass - - wrapped_somefunction = self.wrap(somefunction) - self.otel.assert_does_not_have_span_named("execute_tool somefunction") - somefunction({"key": "value"}) - self.otel.assert_does_not_have_span_named("execute_tool somefunction") - wrapped_somefunction({"key": "value"}) - self.otel.assert_has_span_named("execute_tool somefunction") - span = self.otel.get_span_named("execute_tool somefunction") self.assertEqual( - span.attributes["code.function.parameters.arg.type"], "dict" + arguments["code.function.parameters.list_arg.type"], "list" ) self.assertEqual( - span.attributes["code.function.parameters.arg.value"], - '{"key": "value"}', + arguments["code.function.parameters.list_arg.value"], [1, 2, 3] ) - - def test_handles_primitive_list_arg(self): - def somefunction(arg=None): - pass - - wrapped_somefunction = self.wrap(somefunction) - self.otel.assert_does_not_have_span_named("execute_tool somefunction") - somefunction([1, 2, 3]) - self.otel.assert_does_not_have_span_named("execute_tool somefunction") - wrapped_somefunction([1, 2, 3]) - self.otel.assert_has_span_named("execute_tool somefunction") - span = self.otel.get_span_named("execute_tool somefunction") self.assertEqual( - span.attributes["code.function.parameters.arg.type"], "list" + arguments["code.function.parameters.heterogenous_list_arg.type"], + "list", ) - # A conversion is required here, because the Open Telemetry code converts the - # list into a tuple. (But this conversion isn't happening in "tool_call_wrapper.py"). self.assertEqual( - list(span.attributes["code.function.parameters.arg.value"]), - [1, 2, 3], + arguments["code.function.parameters.heterogenous_list_arg.value"], + [123, "abc"], ) - def test_handles_heterogenous_list_arg(self): + def test_handle_with_different_capture_content_on_span_config(self): def somefunction(arg=None): - pass - - wrapped_somefunction = self.wrap(somefunction) - self.otel.assert_does_not_have_span_named("execute_tool somefunction") - somefunction([123, "abc"]) - self.otel.assert_does_not_have_span_named("execute_tool somefunction") - wrapped_somefunction([123, "abc"]) - self.otel.assert_has_span_named("execute_tool somefunction") - span = self.otel.get_span_named("execute_tool somefunction") - self.assertEqual( - span.attributes["code.function.parameters.arg.type"], "list" - ) - self.assertEqual( - span.attributes["code.function.parameters.arg.value"], - '[123, "abc"]', - ) - - def test_handle_with_new_sem_conv(self): - def somefunction(arg=None): - pass - - for mode in ContentCapturingMode: - with self.subTest(f"mode: {mode}"): + return arg + + for capture_content_on_span in ["SPAN_AND_EVENT", "NO_CONTENT"]: + patched_environ = patch.dict( + "os.environ", + { + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": capture_content_on_span, + "OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental", + }, + ) + with patched_environ: + _OpenTelemetrySemanticConventionStability._initialized = False + _OpenTelemetrySemanticConventionStability._initialize() self.setUp() - 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 + wrapped_somefunction = self.wrap(somefunction) + wrapped_somefunction("a string value") + span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual( + span.attributes["gen_ai.tool.name"], "somefunction" + ) + + if capture_content_on_span == "NO_CONTENT": + self.assertNotIn( + "gen_ai.tool.call.arguments", + span.attributes, ) - _OpenTelemetrySemanticConventionStability._initialize() - wrapped_somefunction = self.wrap(somefunction) - wrapped_somefunction(12345) - - span = self.otel.get_span_named( - "execute_tool somefunction" + self.assertNotIn( + "gen_ai.tool.call.result", + span.attributes, + ) + else: + self.assertEqual( + span.attributes["gen_ai.tool.call.result"], + '"a string value"', + ) + arguments = json.loads( + span.attributes["gen_ai.tool.call.arguments"] + ) + self.assertEqual( + arguments["code.function.parameters.arg.type"], "str" ) + self.assertEqual( + arguments["code.function.parameters.arg.value"], + "a string value", + ) + self.tearDown() + + def test_function_that_throws_exception(self): + def somefunction(arg=None): + raise Exception("Something went wrong") - if mode in [ - ContentCapturingMode.NO_CONTENT, - ContentCapturingMode.EVENT_ONLY, - ]: - self.assertNotIn( - "code.function.parameters.arg.value", - span.attributes, - ) - else: - self.assertIn( - "code.function.parameters.arg.value", - span.attributes, - ) - self.tearDown() + wrapped_somefunction = self.wrap(somefunction) + try: + wrapped_somefunction(12345) + except Exception: + span = self.otel.get_span_named("execute_tool somefunction") + self.assertEqual(span.attributes["error.type"], "Exception")