diff --git a/dev-requirements.txt b/dev-requirements.txt index 2668677cc5..7b0a1628ce 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -6,6 +6,7 @@ sphinx-rtd-theme==2.0.0rc4 sphinx-autodoc-typehints==1.25.2 pytest==7.4.4 pytest-cov==4.1.0 +pytest-vcr==1.0.2 readme-renderer==42.0 bleach==4.1.0 # transient dependency for readme-renderer markupsafe>=2.0.1 diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/CHANGELOG.md b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/CHANGELOG.md new file mode 100644 index 0000000000..3bd68460ea --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +- Initial Cohere instrumentation + ([#3081](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3081)) diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/LICENSE b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/README.rst b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/README.rst new file mode 100644 index 0000000000..f167d4e6c8 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/README.rst @@ -0,0 +1,29 @@ +OpenTelemetry Cohere Instrumentation +==================================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-cohere-v2.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-cohere-v2/ + +This library allows tracing LLM requests and logging of messages made by the +`Cohere Python API library `_. + + +Installation +------------ + +If your application is already instrumented with OpenTelemetry, add this +package to your requirements. +:: + + pip install opentelemetry-instrumentation-cohere-v2 + +If you don't have an Cohere application, yet, try our `example `_ +which only needs a valid Cohere API key. + +References +---------- +* `OpenTelemetry Cohere Instrumentation `_ +* `OpenTelemetry Project `_ +* `OpenTelemetry Python Examples `_ diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/example/main.py b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/example/main.py new file mode 100644 index 0000000000..78eed6d518 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/example/main.py @@ -0,0 +1,25 @@ +import cohere + +from opentelemetry import trace +from opentelemetry.instrumentation.cohere_v2 import CohereInstrumentor +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + ConsoleSpanExporter, +) + +CohereInstrumentor().instrument() + +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor( + BatchSpanProcessor(ConsoleSpanExporter()) +) +tracer = trace.get_tracer(__name__) + +co = cohere.ClientV2() + +with tracer.start_as_current_span("foo"): + response = co.chat( + model="command-r-plus", + messages=[{"role": "user", "content": "Write a short poem on OpenTelemetry."}] + ) diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/pyproject.toml new file mode 100644 index 0000000000..9d0fed4428 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-cohere-v2" +dynamic = ["version"] +description = "OpenTelemetry Official Cohere instrumentation" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.8" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "opentelemetry-api ~= 1.28", + "opentelemetry-instrumentation ~= 0.49b0", + "opentelemetry-semantic-conventions ~= 0.49b0" +] + +[project.optional-dependencies] +instruments = [ + "cohere >= 5.11.4", +] + +[project.entry-points.opentelemetry_instrumentor] +openai = "opentelemetry.instrumentation.cohere_v2:CohereInstrumentor" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation-genai/opentelemetry-instrumentation-cohere-v2" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/cohere_v2/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/__init__.py new file mode 100644 index 0000000000..fedcc04b89 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/__init__.py @@ -0,0 +1,93 @@ +# 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. + +""" +Cohere client instrumentation supporting `cohere`, it can be enabled by +using ``CohereInstrumentor``. + +.. _openai: https://pypi.org/project/cohere/ + +Usage +----- + +.. code:: python + + import cohere + from opentelemetry.instrumentation.cohere_v2 import CohereInstrumentor + + CohereInstrumentor().instrument() + + co = cohere.ClientV2('') + + response = co.chat( + model="command-r-plus", + messages=[{"role": "user", "content": "Write a short poem on OpenTelemetry."}] + ) + +API +--- +""" + +from typing import Collection + +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.cohere_v2.package import _instruments +from opentelemetry.instrumentation.cohere_v2.version import __version__ +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.semconv.schemas import Schemas +from opentelemetry.trace import get_tracer + +from .patch import client_chat + + +class CohereInstrumentor(BaseInstrumentor): + """An instrumentor for Cohere's client library.""" + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + """Enable Cohere instrumentation.""" + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer( + __name__, + __version__, + tracer_provider, + schema_url=Schemas.V1_28_0.value, + ) + event_logger_provider = kwargs.get("event_logger_provider") + event_logger = get_event_logger( + __name__, + __version__, + schema_url=Schemas.V1_28_0.value, + event_logger_provider=event_logger_provider, + ) + + wrap_function_wrapper( + module="cohere.client_v2", + name="ClientV2.chat", + wrapper=client_chat( + tracer, event_logger, is_content_enabled() + ), + ) + + + def _uninstrument(self, **kwargs): + import cohere # pylint: disable=import-outside-toplevel + + unwrap("cohere.client_v2.ClientV2", "chat") diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/package.py b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/package.py new file mode 100644 index 0000000000..b7be16a26f --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/package.py @@ -0,0 +1,16 @@ +# 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. + + +_instruments = ("cohere >= 5.11.4",) diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/patch.py b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/patch.py new file mode 100644 index 0000000000..e117899d04 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/patch.py @@ -0,0 +1,66 @@ +# 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 opentelemetry._events import EventLogger +from opentelemetry.trace import SpanKind, Tracer +from opentelemetry.instrumentation.genai_utils import ( + get_span_name, + handle_span_exception +) +from opentelemetry.instrumentation.utils import is_instrumentation_enabled +from .utils import ( + get_genai_request_attributes, + message_to_event, + set_response_attributes, + set_server_address_and_port, +) + + +def client_chat( + tracer: Tracer, event_logger: EventLogger, capture_content: bool +): + """Wrap the `chat` method of the `ClientV2` class to trace it.""" + + def traced_method(wrapped, instance, args, kwargs): + if not is_instrumentation_enabled(): + return wrapped(*args, **kwargs) + + span_attributes = {**get_genai_request_attributes(kwargs, instance)} + set_server_address_and_port(instance, span_attributes) + span_name = get_span_name(span_attributes) + + with tracer.start_as_current_span( + name=span_name, + kind=SpanKind.CLIENT, + attributes=span_attributes, + ) as span: + if span.is_recording(): + for message in kwargs.get("messages", []): + event_logger.emit( + message_to_event(message, capture_content) + ) + + try: + result = wrapped(*args, **kwargs) + if span.is_recording(): + set_response_attributes( + span, result, event_logger, capture_content + ) + return result + + except Exception as error: + handle_span_exception(span, error) + raise + + return traced_method diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/utils.py b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/utils.py new file mode 100644 index 0000000000..26e46005d5 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/utils.py @@ -0,0 +1,199 @@ +# 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. +import cohere + +from typing import List, Optional, Union +from urllib.parse import urlparse + +from opentelemetry._events import Event, EventLogger +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) +from opentelemetry.semconv._incubating.attributes import ( + server_attributes as ServerAttributes, +) +from opentelemetry.trace import Span + + +def extract_tool_calls(item: Union[cohere.types.ChatMessageV2, cohere.AssistantMessageResponse], capture_content: bool): + tool_calls: Optional[List[cohere.ToolCallV2]] = get_property_value(item, "tool_calls") + if tool_calls is None: + return None + + calls = [] + for tool_call in tool_calls: + tool_call_dict = {} + call_id = get_property_value(tool_call, "id") + if call_id: + tool_call_dict["id"] = call_id + + tool_type = get_property_value(tool_call, "type") + if tool_type: + tool_call_dict["type"] = tool_type + + func = get_property_value(tool_call, "function") + if func: + tool_call_dict["function"] = {} + + name = get_property_value(func, "name") + if name: + tool_call_dict["function"]["name"] = name + + arguments = get_property_value(func, "arguments") + if capture_content and arguments: + if isinstance(arguments, str): + arguments = arguments.replace("\n", "") + tool_call_dict["function"]["arguments"] = arguments + + calls.append(tool_call_dict) + return calls + + +def get_property_value(obj, property_name): + if isinstance(obj, dict): + return obj.get(property_name, None) + + return getattr(obj, property_name, None) + + +def set_server_address_and_port(client_instance: cohere.client_v2.V2Client, attributes): + base_client = getattr(client_instance, "_client_wrapper", None) + base_url = getattr(base_client, "_base_url", None) + if not base_url: + return + + port = -1 + url = urlparse(base_url) + attributes[ServerAttributes.SERVER_ADDRESS] = url.hostname + port = url.port + + if port and port != 443 and port > 0: + attributes[ServerAttributes.SERVER_PORT] = port + + +def get_genai_request_attributes( + kwargs, + client_instance: cohere.client_v2.V2Client, + operation_name=GenAIAttributes.GenAiOperationNameValues.CHAT.value, +): + attributes = { + GenAIAttributes.GEN_AI_OPERATION_NAME: operation_name, + GenAIAttributes.GEN_AI_SYSTEM: GenAIAttributes.GenAiSystemValues.COHERE.value, + GenAIAttributes.GEN_AI_REQUEST_MODEL: kwargs.get("model"), + GenAIAttributes.GEN_AI_REQUEST_MAX_TOKENS: kwargs.get("max_tokens"), + GenAIAttributes.GEN_AI_REQUEST_STOP_SEQUENCES: kwargs.get( + "stop_sequences" + ), + GenAIAttributes.GEN_AI_REQUEST_TEMPERATURE: kwargs.get("temperature"), + # TODO: Add to sem conv + "gen_ai.cohere.request.seed": kwargs.get("seed"), + GenAIAttributes.GEN_AI_REQUEST_PRESENCE_PENALTY: kwargs.get( + "presence_penalty" + ), + GenAIAttributes.GEN_AI_REQUEST_FREQUENCY_PENALTY: kwargs.get( + "frequency_penalty" + ), + GenAIAttributes.GEN_AI_REQUEST_TOP_K: kwargs.get("k"), + GenAIAttributes.GEN_AI_REQUEST_TOP_P: kwargs.get("p"), + } + response_format = kwargs.get("response_format") + if response_format: + # TODO: Add to sem conv + attributes["gen_ai.cohere.request.response_format"] = response_format.type + + set_server_address_and_port(client_instance, attributes) + + # filter out None values + return {k: v for k, v in attributes.items() if v is not None} + + +def message_to_event(message: cohere.types.ChatMessageV2, capture_content: bool) -> Event: + attributes = { + GenAIAttributes.GEN_AI_SYSTEM: GenAIAttributes.GenAiSystemValues.COHERE.value + } + role = get_property_value(message, "role") + content = get_property_value(message, "content") + + body = {} + if capture_content and content: + body["content"] = content + if role == "assistant": + tool_calls = extract_tool_calls(message, capture_content) + if tool_calls: + body = {"tool_calls": tool_calls} + elif role == "tool": + tool_call_id = get_property_value(message, "tool_call_id") + if tool_call_id: + body["id"] = tool_call_id + + return Event( + name=f"gen_ai.{role}.message", + attributes=attributes, + body=body if body else None, + ) + + +def set_response_attributes( + span: Span, result: cohere.ChatResponse, event_logger: EventLogger, capture_content: bool +): + event_logger.emit(_response_to_event(result, capture_content)) + + span.set_attribute( + GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS, + [result.finish_reason], + ) + + if getattr(result, "id", None): + span.set_attribute(GenAIAttributes.GEN_AI_RESPONSE_ID, result.id) + + # Get the usage + if getattr(result, "usage", None): + if getattr(result.usage, "tokens"): + span.set_attribute( + GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS, + result.usage.tokens.input_tokens, + ) + span.set_attribute( + GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS, + result.usage.tokens.output_tokens, + ) + + +def _response_to_event(response: cohere.ChatResponse, capture_content): + attributes = { + GenAIAttributes.GEN_AI_SYSTEM: GenAIAttributes.GenAiSystemValues.COHERE.value + } + + body = { + "id": response.id, + "finish_reason": response.finish_reason or "error", + } + + if response.message: + message = {} + if response.message.role and response.message.role != "assistant": + message["role"] = response.message.role + tool_calls = extract_tool_calls(response.message, capture_content) + if tool_calls: + message["tool_calls"] = tool_calls + content: List[cohere.AssistantMessageResponseContentItem] = get_property_value(response.message, "content") + if capture_content and content: + message["content"] = content + body["message"] = message + + return Event( + name="gen_ai.choice", + attributes=attributes, + body=body, + ) \ No newline at end of file diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/version.py b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/version.py new file mode 100644 index 0000000000..61ae9b7c25 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/src/opentelemetry/instrumentation/cohere_v2/version.py @@ -0,0 +1,15 @@ +# 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. + +__version__ = "2.0b0.dev" diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/tests/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/tests/cassettes/test_chat_with_content.yaml b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/tests/cassettes/test_chat_with_content.yaml new file mode 100644 index 0000000000..95650ec93f --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/tests/cassettes/test_chat_with_content.yaml @@ -0,0 +1,108 @@ +interactions: +- request: + body: |- + { + "model": "command-r-plus", + "messages": [ + { + "role": "user", + "content": "Say this is a test" + } + ], + "stream": false + } + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - Bearer test_cohere_api_key + connection: + - keep-alive + content-length: + - '109' + content-type: + - application/json + host: + - api.cohere.com + user-agent: + - python-httpx/0.27.2 + x-fern-language: + - Python + x-fern-sdk-name: + - cohere + x-fern-sdk-version: + - 5.11.4 + method: POST + uri: https://api.cohere.com/v2/chat + response: + body: + string: |- + { + "id": "68d63b3a-360d-4460-9cb9-8413ef2adc52", + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "This is a test." + } + ] + }, + "finish_reason": "COMPLETE", + "usage": { + "billed_units": { + "input_tokens": 5, + "output_tokens": 5 + }, + "tokens": { + "input_tokens": 198, + "output_tokens": 5 + } + } + } + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Length: + - '266' + Set-Cookie: test_set_cookie + Via: + - 1.1 google + access-control-expose-headers: + - X-Debug-Trace-ID + cache-control: + - no-cache, no-store, no-transform, must-revalidate, private, max-age=0 + content-type: + - application/json + date: + - Tue, 10 Dec 2024 16:04:21 GMT + expires: + - Thu, 01 Jan 1970 00:00:00 UTC + num_chars: + - '1188' + num_tokens: + - '10' + pragma: + - no-cache + server: + - envoy + vary: + - Origin + x-accel-expires: + - '0' + x-debug-trace-id: + - 90f329169476c8dd3d7bf0071236f7f8 + x-endpoint-monthly-call-limit: + - '1000' + x-envoy-upstream-service-time: + - '188' + x-trial-endpoint-call-limit: + - '40' + x-trial-endpoint-call-remaining: + - '39' + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/tests/conftest.py b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/tests/conftest.py new file mode 100644 index 0000000000..318d62548d --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/tests/conftest.py @@ -0,0 +1,178 @@ +"""Unit tests configuration module.""" + +import json +import os + +import pytest +import yaml +from cohere import ClientV2 + +from opentelemetry.instrumentation.cohere_v2 import CohereInstrumentor +from opentelemetry.instrumentation.genai_utils import ( + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, +) +from opentelemetry.sdk._events import EventLoggerProvider +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import ( + InMemoryLogExporter, + SimpleLogRecordProcessor, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + + +@pytest.fixture(scope="function", name="span_exporter") +def fixture_span_exporter(): + exporter = InMemorySpanExporter() + yield exporter + + +@pytest.fixture(scope="function", name="log_exporter") +def fixture_log_exporter(): + exporter = InMemoryLogExporter() + yield exporter + + +@pytest.fixture(scope="function", name="tracer_provider") +def fixture_tracer_provider(span_exporter): + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + return provider + + +@pytest.fixture(scope="function", name="event_logger_provider") +def fixture_event_logger_provider(log_exporter): + provider = LoggerProvider() + provider.add_log_record_processor(SimpleLogRecordProcessor(log_exporter)) + event_logger_provider = EventLoggerProvider(provider) + + return event_logger_provider + + +@pytest.fixture(autouse=True) +def environment(): + if not os.getenv("CO_API_KEY"): + os.environ["CO_API_KEY"] = "test_cohere_api_key" + + +@pytest.fixture +def cohere_client(): + return ClientV2() + + +@pytest.fixture(scope="module") +def vcr_config(): + return { + "filter_headers": [ + ("cookie", "test_cookie"), + ("authorization", "Bearer test_cohere_api_key"), + ], + "decode_compressed_response": True, + "before_record_response": scrub_response_headers, + } + + +@pytest.fixture(scope="function") +def instrument_no_content(tracer_provider, event_logger_provider): + instrumentor = CohereInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + event_logger_provider=event_logger_provider, + ) + + yield instrumentor + instrumentor.uninstrument() + + +@pytest.fixture(scope="function") +def instrument_with_content(tracer_provider, event_logger_provider): + os.environ.update( + {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "True"} + ) + instrumentor = CohereInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + event_logger_provider=event_logger_provider, + ) + + yield instrumentor + os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) + instrumentor.uninstrument() + + +class LiteralBlockScalar(str): + """Formats the string as a literal block scalar, preserving whitespace and + without interpreting escape characters""" + + +def literal_block_scalar_presenter(dumper, data): + """Represents a scalar string as a literal block, via '|' syntax""" + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") + + +yaml.add_representer(LiteralBlockScalar, literal_block_scalar_presenter) + + +def process_string_value(string_value): + """Pretty-prints JSON or returns long strings as a LiteralBlockScalar""" + try: + json_data = json.loads(string_value) + return LiteralBlockScalar(json.dumps(json_data, indent=2)) + except (ValueError, TypeError): + if len(string_value) > 80: + return LiteralBlockScalar(string_value) + return string_value + + +def convert_body_to_literal(data): + """Searches the data for body strings, attempting to pretty-print JSON""" + if isinstance(data, dict): + for key, value in data.items(): + # Handle response body case (e.g., response.body.string) + if key == "body" and isinstance(value, dict) and "string" in value: + value["string"] = process_string_value(value["string"]) + + # Handle request body case (e.g., request.body) + elif key == "body" and isinstance(value, str): + data[key] = process_string_value(value) + + else: + convert_body_to_literal(value) + + elif isinstance(data, list): + for idx, choice in enumerate(data): + data[idx] = convert_body_to_literal(choice) + + return data + + +class PrettyPrintJSONBody: + """This makes request and response body recordings more readable.""" + + @staticmethod + def serialize(cassette_dict): + cassette_dict = convert_body_to_literal(cassette_dict) + return yaml.dump( + cassette_dict, default_flow_style=False, allow_unicode=True + ) + + @staticmethod + def deserialize(cassette_string): + return yaml.load(cassette_string, Loader=yaml.Loader) + + +@pytest.fixture(scope="module", autouse=True) +def fixture_vcr(vcr): + vcr.register_serializer("yaml", PrettyPrintJSONBody) + return vcr + + +def scrub_response_headers(response): + """ + This scrubs sensitive response headers. Note they are case-sensitive! + """ + response["headers"]["Set-Cookie"] = "test_set_cookie" + return response diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/tests/test_client_chat.py b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/tests/test_client_chat.py new file mode 100644 index 0000000000..4d2d719e75 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere-v2/tests/test_client_chat.py @@ -0,0 +1,154 @@ +# 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. +# pylint: disable=too-many-locals + +from typing import Optional + +import pytest +from cohere import ChatResponse + +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.semconv._incubating.attributes import ( + error_attributes as ErrorAttributes, +) +from opentelemetry.semconv._incubating.attributes import ( + event_attributes as EventAttributes, +) +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) +from opentelemetry.semconv._incubating.attributes import ( + server_attributes as ServerAttributes, +) + + +@pytest.mark.vcr() +def test_chat_with_content( + span_exporter, log_exporter, instrument_with_content, cohere_client +): + llm_model_value = "command-r-plus" + messages_value = [{"role": "user", "content": "Say this is a test"}] + + response = cohere_client.chat( + messages=messages_value, model=llm_model_value, + ) + + spans = span_exporter.get_finished_spans() + assert_chat_attributes(spans[0], llm_model_value, response) + + logs = log_exporter.get_finished_logs() + assert len(logs) == 2 + + user_message = {"content": messages_value[0]["content"]} + assert_message_in_logs( + logs[0], "gen_ai.user.message", user_message, spans[0] + ) + + response_event = { + "id": response.id, + "finish_reason": "COMPLETE", + "message": { + "content": response.message.content, + }, + } + assert_message_in_logs(logs[1], "gen_ai.choice", response_event, spans[0]) + + +def assert_message_in_logs(log, event_name, expected_content, parent_span): + assert log.log_record.attributes[EventAttributes.EVENT_NAME] == event_name + assert ( + log.log_record.attributes[GenAIAttributes.GEN_AI_SYSTEM] + == GenAIAttributes.GenAiSystemValues.COHERE.value + ) + + if not expected_content: + assert not log.log_record.body + else: + assert log.log_record.body + assert dict(log.log_record.body) == expected_content + assert_log_parent(log, parent_span) + + +def assert_chat_attributes( + span: ReadableSpan, + request_model: str, + response: ChatResponse, + operation_name: str = "chat", + server_address: str = "api.cohere.com", +): + return assert_all_attributes( + span, + request_model, + response.id, + response.usage.tokens.input_tokens, + response.usage.tokens.output_tokens, + operation_name, + server_address, + ) + + +def assert_all_attributes( + span: ReadableSpan, + request_model: str, + response_id: str = None, + input_tokens: Optional[int] = None, + output_tokens: Optional[int] = None, + operation_name: str = "chat", + server_address: str = "api.cohere.com", +): + assert span.name == f"{operation_name} {request_model}" + assert ( + operation_name + == span.attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] + ) + assert ( + GenAIAttributes.GenAiSystemValues.COHERE.value + == span.attributes[GenAIAttributes.GEN_AI_SYSTEM] + ) + assert ( + request_model == span.attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL] + ) + + if response_id: + assert ( + response_id == span.attributes[GenAIAttributes.GEN_AI_RESPONSE_ID] + ) + else: + assert GenAIAttributes.GEN_AI_RESPONSE_ID not in span.attributes + + if input_tokens: + assert ( + input_tokens + == span.attributes[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] + ) + else: + assert GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS not in span.attributes + + if output_tokens: + assert ( + output_tokens + == span.attributes[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] + ) + else: + assert ( + GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS not in span.attributes + ) + + assert server_address == span.attributes[ServerAttributes.SERVER_ADDRESS] + + +def assert_log_parent(log, span): + assert log.log_record.trace_id == span.get_span_context().trace_id + assert log.log_record.span_id == span.get_span_context().span_id + assert log.log_record.trace_flags == span.get_span_context().trace_flags diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/README.rst index d2cb0b5724..989ebf37e8 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/README.rst @@ -76,7 +76,7 @@ To uninstrument clients, call the uninstrument method: References ---------- -* `OpenTelemetry OpenAI Instrumentation `_ +* `OpenTelemetry OpenAI Instrumentation `_ * `OpenTelemetry Project `_ -* `OpenTelemetry Python Examples `_ +* `OpenTelemetry Python Examples `_ 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..b5673d36d6 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 @@ -47,7 +47,7 @@ from opentelemetry._events import get_event_logger 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.genai_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..809e1fad6d 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 @@ -26,22 +26,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 +167,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 +211,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..f9db12c4a7 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/genai_utils.py @@ -0,0 +1,51 @@ +# 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()