From 5f8e2236f02e9d684e9be83161e5e740aeb36b5d Mon Sep 17 00:00:00 2001 From: Aaron Abbott Date: Sat, 11 Jan 2025 03:53:44 +0000 Subject: [PATCH] Add common gen AI utils into opentelemetry-instrumentation --- .../instrumentation/openai_v2/__init__.py | 2 +- .../instrumentation/openai_v2/patch.py | 13 ++- .../instrumentation/openai_v2/utils.py | 28 +----- .../tests/conftest.py | 4 +- .../instrumentation/genai_utils.py | 50 ++++++++++ .../tests/test_genai_utils.py | 91 +++++++++++++++++++ 6 files changed, 153 insertions(+), 35 deletions(-) create mode 100644 opentelemetry-instrumentation/src/opentelemetry/instrumentation/genai_utils.py create mode 100644 opentelemetry-instrumentation/tests/test_genai_utils.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/__init__.py index ee3bbfdb73..6ddc27748e 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/__init__.py @@ -45,9 +45,9 @@ from wrapt import wrap_function_wrapper from opentelemetry._events import get_event_logger +from opentelemetry.instrumentation.genai_utils import is_content_enabled from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.openai_v2.package import _instruments -from opentelemetry.instrumentation.openai_v2.utils import is_content_enabled from opentelemetry.instrumentation.utils import unwrap from opentelemetry.semconv.schemas import Schemas from opentelemetry.trace import get_tracer diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py index cd284473ce..4f56bc6c8f 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py @@ -18,6 +18,10 @@ from openai import Stream from opentelemetry._events import Event, EventLogger +from opentelemetry.instrumentation.genai_utils import ( + get_span_name, + handle_span_exception, +) from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, ) @@ -25,8 +29,7 @@ from .utils import ( choice_to_event, - get_llm_request_attributes, - handle_span_exception, + get_genai_request_attributes, is_streaming, message_to_event, set_span_attribute, @@ -39,9 +42,9 @@ def chat_completions_create( """Wrap the `create` method of the `ChatCompletion` class to trace it.""" def traced_method(wrapped, instance, args, kwargs): - span_attributes = {**get_llm_request_attributes(kwargs, instance)} + span_attributes = {**get_genai_request_attributes(kwargs, instance)} - span_name = f"{span_attributes[GenAIAttributes.GEN_AI_OPERATION_NAME]} {span_attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL]}" + span_name = get_span_name(span_attributes) with tracer.start_as_current_span( name=span_name, kind=SpanKind.CLIENT, @@ -81,7 +84,7 @@ def async_chat_completions_create( """Wrap the `create` method of the `AsyncChatCompletion` class to trace it.""" async def traced_method(wrapped, instance, args, kwargs): - span_attributes = {**get_llm_request_attributes(kwargs, instance)} + span_attributes = {**get_genai_request_attributes(kwargs, instance)} span_name = f"{span_attributes[GenAIAttributes.GEN_AI_OPERATION_NAME]} {span_attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL]}" with tracer.start_as_current_span( diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py index f8a837259e..7ade5ea39d 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from os import environ from typing import Mapping, Optional, Union from urllib.parse import urlparse @@ -26,22 +25,6 @@ from opentelemetry.semconv._incubating.attributes import ( server_attributes as ServerAttributes, ) -from opentelemetry.semconv.attributes import ( - error_attributes as ErrorAttributes, -) -from opentelemetry.trace.status import Status, StatusCode - -OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = ( - "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" -) - - -def is_content_enabled() -> bool: - capture_content = environ.get( - OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, "false" - ) - - return capture_content.lower() == "true" def extract_tool_calls(item, capture_content): @@ -183,7 +166,7 @@ def non_numerical_value_is_set(value: Optional[Union[bool, str]]): return bool(value) and value != NOT_GIVEN -def get_llm_request_attributes( +def get_genai_request_attributes( kwargs, client_instance, operation_name=GenAIAttributes.GenAiOperationNameValues.CHAT.value, @@ -227,12 +210,3 @@ def get_llm_request_attributes( # filter out None values return {k: v for k, v in attributes.items() if v is not None} - - -def handle_span_exception(span, error): - span.set_status(Status(StatusCode.ERROR, str(error))) - if span.is_recording(): - span.set_attribute( - ErrorAttributes.ERROR_TYPE, type(error).__qualname__ - ) - span.end() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/conftest.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/conftest.py index 18e6582dff..76e3db6d7c 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/conftest.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/tests/conftest.py @@ -7,10 +7,10 @@ import yaml from openai import AsyncOpenAI, OpenAI -from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor -from opentelemetry.instrumentation.openai_v2.utils import ( +from opentelemetry.instrumentation.genai_utils import ( OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, ) +from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor from opentelemetry.sdk._events import EventLoggerProvider from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk._logs.export import ( diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/genai_utils.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/genai_utils.py new file mode 100644 index 0000000000..3dc18d55a4 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/genai_utils.py @@ -0,0 +1,50 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from os import environ + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) +from opentelemetry.semconv.attributes import ( + error_attributes as ErrorAttributes, +) +from opentelemetry.trace.status import Status, StatusCode + +OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = ( + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" +) + + +def is_content_enabled() -> bool: + capture_content = environ.get( + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, "false" + ) + + return capture_content.lower() == "true" + + +def get_span_name(span_attributes): + name = span_attributes.get(GenAIAttributes.GEN_AI_OPERATION_NAME, "") + model = span_attributes.get(GenAIAttributes.GEN_AI_REQUEST_MODEL, "") + return f"{name} {model}" + + +def handle_span_exception(span, error): + span.set_status(Status(StatusCode.ERROR, str(error))) + if span.is_recording(): + span.set_attribute( + ErrorAttributes.ERROR_TYPE, type(error).__qualname__ + ) + span.end() diff --git a/opentelemetry-instrumentation/tests/test_genai_utils.py b/opentelemetry-instrumentation/tests/test_genai_utils.py new file mode 100644 index 0000000000..b61d4987e2 --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_genai_utils.py @@ -0,0 +1,91 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import patch + +from opentelemetry.instrumentation.genai_utils import ( + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, + get_span_name, + handle_span_exception, + is_content_enabled, +) +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.test.test_base import TestBase +from opentelemetry.trace.status import StatusCode + + +class MyTestException(Exception): + pass + + +class TestGenaiUtils(TestBase): + @patch.dict( + "os.environ", + {}, + ) + def test_is_content_enabled_default(self): + self.assertFalse(is_content_enabled()) + + def test_is_content_enabled_true(self): + for env_value in "true", "TRUE", "True", "tRue": + with patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: env_value + }, + ): + self.assertTrue(is_content_enabled()) + + def test_is_content_enabled_false(self): + for env_value in "false", "FALSE", "False", "fAlse": + with patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: env_value + }, + ): + self.assertFalse(is_content_enabled()) + + def test_get_span_name(self): + span_attributes = { + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "mymodel", + } + self.assertEqual(get_span_name(span_attributes), "chat mymodel") + + span_attributes = { + "gen_ai.operation.name": "chat", + } + self.assertEqual(get_span_name(span_attributes), "chat ") + + span_attributes = { + "gen_ai.request.model": "mymodel", + } + self.assertEqual(get_span_name(span_attributes), " mymodel") + + span_attributes = {} + self.assertEqual(get_span_name(span_attributes), " ") + + def test_handle_span_exception(self): + tracer = self.tracer_provider.get_tracer("test_handle_span_exception") + with tracer.start_as_current_span("foo") as span: + handle_span_exception(span, MyTestException()) + + self.assertEqual(len(self.get_finished_spans()), 1) + finished_span: ReadableSpan = self.get_finished_spans()[0] + self.assertEqual(finished_span.name, "foo") + self.assertIs(finished_span.status.status_code, StatusCode.ERROR) + self.assertEqual( + finished_span.attributes["error.type"], "MyTestException" + )