diff --git a/instrumentation/README.md b/instrumentation/README.md index 48ee952f..017cf261 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -2,6 +2,7 @@ | Instrumentation | Supported Packages | Metrics support | Semconv status | | --------------- | ------------------ | --------------- | -------------- | | [opentelemetry-instrumentation-genai-anthropic](./opentelemetry-instrumentation-genai-anthropic) | anthropic >= 0.16.0 | No | development +| [opentelemetry-instrumentation-genai-bedrock](./opentelemetry-instrumentation-genai-bedrock) | botocore >= 1.35.0 | No | development | [opentelemetry-instrumentation-genai-claude-agent-sdk](./opentelemetry-instrumentation-genai-claude-agent-sdk) | claude-agent-sdk >= 0.1.14 | No | development | [opentelemetry-instrumentation-genai-langchain](./opentelemetry-instrumentation-genai-langchain) | langchain >= 0.3.21 | No | development | [opentelemetry-instrumentation-genai-openai](./opentelemetry-instrumentation-genai-openai) | openai >= 1.26.0 | Yes | development diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/.changelog/.gitignore b/instrumentation/opentelemetry-instrumentation-genai-bedrock/.changelog/.gitignore new file mode 100644 index 00000000..f935021a --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/.changelog/.gitignore @@ -0,0 +1 @@ +!.gitignore diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/.changelog/93.added b/instrumentation/opentelemetry-instrumentation-genai-bedrock/.changelog/93.added new file mode 100644 index 00000000..0bf646c7 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/.changelog/93.added @@ -0,0 +1 @@ +Initial release with Converse and ConverseStream instrumentation. diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/LICENSE b/instrumentation/opentelemetry-instrumentation-genai-bedrock/LICENSE new file mode 100644 index 00000000..e294301d --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/LICENSE @@ -0,0 +1,202 @@ +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 Support. 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 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. + diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/README.rst b/instrumentation/opentelemetry-instrumentation-genai-bedrock/README.rst new file mode 100644 index 00000000..c853b92a --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/README.rst @@ -0,0 +1,63 @@ +OpenTelemetry AWS Bedrock Instrumentation +========================================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-genai-bedrock.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-genai-bedrock/ + +This library allows tracing LLM requests made via the +`AWS Bedrock Runtime `_ +``Converse`` and ``ConverseStream`` APIs using ``botocore``. + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation-genai-bedrock + +Usage +----- + +This section describes how to set up AWS Bedrock instrumentation if you're setting OpenTelemetry up manually. + +.. code-block:: python + + from opentelemetry.instrumentation.genai.bedrock import BedrockInstrumentor + import boto3 + + # Instrument Bedrock + BedrockInstrumentor().instrument() + + # Use Bedrock Runtime client as normal + client = boto3.client("bedrock-runtime", region_name="us-east-1") + response = client.converse( + modelId="anthropic.claude-3-5-sonnet-20241022-v2:0", + messages=[ + {"role": "user", "content": [{"text": "Hello, Claude!"}]} + ], + ) + + +Configuration +------------- + +Capture Message Content +*********************** + +By default, prompts and completions are not captured. To enable message content capture, +set the environment variable: + +:: + + export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true + + +References +---------- + +* `OpenTelemetry Project `_ +* `OpenTelemetry GenAI semantic conventions `_ +* `AWS Bedrock Documentation `_ +* `botocore `_ diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/pyproject.toml b/instrumentation/opentelemetry-instrumentation-genai-bedrock/pyproject.toml new file mode 100644 index 00000000..e6adccb4 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/pyproject.toml @@ -0,0 +1,91 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-genai-bedrock" +dynamic = ["version"] +description = "OpenTelemetry AWS Bedrock instrumentation" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +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.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "opentelemetry-api ~= 1.40", + "opentelemetry-instrumentation ~= 0.61b0", + "opentelemetry-semantic-conventions ~= 0.61b0", + "opentelemetry-util-genai >= 1.0b0.dev", +] + +[project.optional-dependencies] +instruments = ["botocore >= 1.35.0"] + +[project.entry-points.opentelemetry_instrumentor] +bedrock = "opentelemetry.instrumentation.genai.bedrock:BedrockInstrumentor" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-genai/tree/main/instrumentation/opentelemetry-instrumentation-genai-bedrock" +Repository = "https://github.com/open-telemetry/opentelemetry-python-genai" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/genai/bedrock/version.py" + +[tool.hatch.build.targets.sdist] +include = ["/src", "/tests"] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "conformance: GenAI semconv conformance scenario (run via the *-conformance tox envs)", +] + +[tool.towncrier] +directory = ".changelog" +filename = "CHANGELOG.md" +start_string = "\n" +template = "../../scripts/changelog_template.j2" +issue_format = "[#{issue}](https://github.com/open-telemetry/opentelemetry-python-genai/pull/{issue})" +wrap = true +issue_pattern = "^(\\d+)" + +[[tool.towncrier.type]] +directory = "added" +name = "Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecated" +name = "Deprecated" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "Removed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "Fixed" +showcontent = true diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/__init__.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/__init__.py new file mode 100644 index 00000000..f5e81837 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/__init__.py @@ -0,0 +1,105 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +# +# Based on the Bedrock extension in opentelemetry-python-contrib by @xrmx: +# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3161 +# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3258 + +""" +OpenTelemetry AWS Bedrock Instrumentation +========================================== + +Instrumentation for the AWS Bedrock Runtime service via ``botocore``. + +Usage +----- + +.. code-block:: python + + from opentelemetry.instrumentation.genai.bedrock import BedrockInstrumentor + import boto3 + + # Enable instrumentation + BedrockInstrumentor().instrument() + + # Use Bedrock Runtime client normally + client = boto3.client("bedrock-runtime", region_name="us-east-1") + response = client.converse( + modelId="anthropic.claude-3-5-sonnet-20241022-v2:0", + messages=[{"role": "user", "content": [{"text": "Hello!"}]}], + ) + +Configuration +------------- + +Message content capture can be enabled by setting the environment variable: +``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`` + +API +--- +""" + +from __future__ import annotations + +from typing import Any, Collection + +from wrapt import wrap_function_wrapper + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.util.genai.handler import TelemetryHandler + +from .package import _instruments +from .patch import make_api_call_wrapper + + +class BedrockInstrumentor(BaseInstrumentor): + """An instrumentor for AWS Bedrock Runtime via botocore. + + This instrumentor automatically traces Bedrock Converse and ConverseStream + API calls and optionally captures message content as events. + """ + + def __init__(self) -> None: + super().__init__() + + # pylint: disable=no-self-use + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs: Any) -> None: + """Enable Bedrock instrumentation. + + Args: + **kwargs: Optional arguments + - tracer_provider: TracerProvider instance + - meter_provider: MeterProvider instance + - logger_provider: LoggerProvider instance + """ + tracer_provider = kwargs.get("tracer_provider") + meter_provider = kwargs.get("meter_provider") + logger_provider = kwargs.get("logger_provider") + + handler = TelemetryHandler( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + logger_provider=logger_provider, + ) + + wrap_function_wrapper( + "botocore.client", + "BaseClient._make_api_call", + make_api_call_wrapper(handler), + ) + + def _uninstrument(self, **kwargs: Any) -> None: + """Disable Bedrock instrumentation. + + This removes all patches applied during instrumentation. + """ + import botocore.client # pylint: disable=import-outside-toplevel # noqa: PLC0415 + + unwrap( + botocore.client.BaseClient, # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType] + "_make_api_call", + ) diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/extractors.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/extractors.py new file mode 100644 index 00000000..ff98d49e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/extractors.py @@ -0,0 +1,188 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +# +# Based on the Bedrock extension in opentelemetry-python-contrib by @xrmx: +# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3161 +# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3258 + +"""Get/extract helpers for AWS Bedrock Converse instrumentation.""" + +from __future__ import annotations + +from typing import Any + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) +from opentelemetry.util.genai.types import ( + InputMessage, + MessagePart, + OutputMessage, + Text, + ToolCallRequest, + ToolCallResponse, +) +from opentelemetry.util.types import AttributeValue + + +def normalize_finish_reason(stop_reason: str | None) -> str | None: + """Normalize Bedrock stop reasons to semconv values.""" + if stop_reason is None: + return None + normalized = { + "end_turn": "stop", + "stop_sequence": "stop", + "max_tokens": "length", + "tool_use": "tool_calls", + "content_filtered": "content_filter", + "guardrail_intervened": "content_filter", + }.get(stop_reason) + return normalized or stop_reason + + +def get_request_attributes( + api_params: dict[str, Any], +) -> dict[str, AttributeValue]: + """Extract GenAI request attributes from Converse API parameters.""" + inference_config = api_params.get("inferenceConfig") or {} + + attributes: dict[str, AttributeValue | None] = { + GenAIAttributes.GEN_AI_OPERATION_NAME: GenAIAttributes.GenAiOperationNameValues.CHAT.value, + GenAIAttributes.GEN_AI_SYSTEM: GenAIAttributes.GenAiSystemValues.AWS_BEDROCK.value, + GenAIAttributes.GEN_AI_REQUEST_MODEL: api_params.get("modelId"), + GenAIAttributes.GEN_AI_REQUEST_MAX_TOKENS: inference_config.get( + "maxTokens" + ), + GenAIAttributes.GEN_AI_REQUEST_TEMPERATURE: inference_config.get( + "temperature" + ), + GenAIAttributes.GEN_AI_REQUEST_TOP_P: inference_config.get("topP"), + GenAIAttributes.GEN_AI_REQUEST_STOP_SEQUENCES: inference_config.get( + "stopSequences" + ), + } + return {k: v for k, v in attributes.items() if v is not None} + + +def get_response_attributes( + result: dict[str, Any], +) -> dict[str, Any]: + """Extract response attributes from a Converse result dict.""" + attrs: dict[str, Any] = {} + + # Stop reason + stop_reason = result.get("stopReason") + finish_reason = normalize_finish_reason(stop_reason) + if finish_reason: + attrs["finish_reasons"] = [finish_reason] + + # Usage tokens + usage = result.get("usage") or {} + input_tokens = usage.get("inputTokens") + output_tokens = usage.get("outputTokens") + if input_tokens is not None: + attrs["input_tokens"] = input_tokens + if output_tokens is not None: + attrs["output_tokens"] = output_tokens + + # Response metadata + response_metadata = result.get("ResponseMetadata") or {} + request_id = response_metadata.get("RequestId") + if request_id: + attrs["response_id"] = request_id + + # Bedrock does not return a response model name in the standard Converse + # response. The model used is the one requested. + attrs["response_model"] = None + + return attrs + + +def get_input_messages( + api_params: dict[str, Any], +) -> list[InputMessage]: + """Extract input messages from Converse API parameters.""" + messages = api_params.get("messages") or [] + result: list[InputMessage] = [] + for message in messages: + role = message.get("role", "user") + content = message.get("content") or [] + parts = _convert_content_blocks_to_parts(content) + result.append(InputMessage(role=role, parts=parts)) + return result + + +def get_system_instruction( + api_params: dict[str, Any], +) -> list[MessagePart]: + """Extract system instruction from Converse API parameters.""" + system = api_params.get("system") or [] + parts: list[MessagePart] = [] + for block in system: + text = block.get("text") + if text is not None: + parts.append(Text(content=str(text))) + return parts + + +def get_output_messages( + result: dict[str, Any], +) -> list[OutputMessage]: + """Extract output messages from a Converse response.""" + output = result.get("output") or {} + message = output.get("message") + if message is None: + return [] + + role = message.get("role", "assistant") + content = message.get("content") or [] + parts = _convert_content_blocks_to_parts(content) + + stop_reason = result.get("stopReason") + finish_reason = normalize_finish_reason(stop_reason) or "" + + return [OutputMessage(role=role, parts=parts, finish_reason=finish_reason)] + + +def _convert_content_blocks_to_parts( + content: list[dict[str, Any]], +) -> list[MessagePart]: + """Convert Bedrock content blocks to MessagePart instances.""" + parts: list[MessagePart] = [] + for block in content: + part = _convert_block_to_part(block) + if part is not None: + parts.append(part) + return parts + + +def _convert_block_to_part(block: dict[str, Any]) -> MessagePart | None: + """Convert a single Bedrock content block to a MessagePart.""" + # Text block + if "text" in block: + return Text(content=str(block["text"])) + + # Tool use block (request) + if "toolUse" in block: + tool_use = block["toolUse"] + return ToolCallRequest( + arguments=tool_use.get("input"), + name=tool_use.get("name", ""), + id=tool_use.get("toolUseId"), + ) + + # Tool result block (response) + if "toolResult" in block: + tool_result = block["toolResult"] + content_parts = tool_result.get("content") or [] + # Flatten text content from tool result + response_text = "" + for part in content_parts: + if "text" in part: + response_text += part["text"] + return ToolCallResponse( + response=response_text or tool_result.get("content"), + id=tool_result.get("toolUseId"), + ) + + return None diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/package.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/package.py new file mode 100644 index 00000000..384dc3bc --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/package.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +_instruments = ("botocore >= 1.35.0",) diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/patch.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/patch.py new file mode 100644 index 00000000..5895d163 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/patch.py @@ -0,0 +1,166 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +# +# Based on the Bedrock extension in opentelemetry-python-contrib by @xrmx: +# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3161 +# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3258 + +"""Patching functions for AWS Bedrock instrumentation.""" + +from __future__ import annotations + +from typing import Any, Callable +from urllib.parse import urlparse + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) +from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.invocation import InferenceInvocation + +from .extractors import ( + get_input_messages, + get_output_messages, + get_request_attributes, + get_response_attributes, + get_system_instruction, +) +from .wrappers import ConverseStreamWrapper + +_SUPPORTED_OPERATIONS = frozenset({"Converse", "ConverseStream"}) +_BEDROCK_RUNTIME_SERVICE = "bedrock-runtime" +_PROVIDER = GenAIAttributes.GenAiSystemValues.AWS_BEDROCK.value + + +def make_api_call_wrapper( + handler: TelemetryHandler, +) -> Callable[..., Any]: + """Wrap ``BaseClient._make_api_call`` to trace Bedrock Runtime calls.""" + capture_content = handler.should_capture_content() + + def traced_method( + wrapped: Callable[..., Any], + instance: Any, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> Any: + # _make_api_call signature: (operation_name, api_params) + if len(args) < 2: + return wrapped(*args, **kwargs) + + operation_name: str = args[0] + api_params: dict[str, Any] = args[1] + + # Only intercept Bedrock Runtime Converse/ConverseStream + service_id = _get_service_id(instance) + if service_id != _BEDROCK_RUNTIME_SERVICE: + return wrapped(*args, **kwargs) + + if operation_name not in _SUPPORTED_OPERATIONS: + return wrapped(*args, **kwargs) + + invocation = _create_invocation( + handler, instance, api_params, capture_content + ) + + try: + result = wrapped(*args, **kwargs) + except Exception as exc: + invocation.fail(exc) + raise + + if operation_name == "ConverseStream": + return ConverseStreamWrapper(result, invocation, capture_content) + + # Non-streaming Converse response + _set_response_attributes(invocation, result, capture_content) + invocation.stop() + return result + + return traced_method + + +def _get_service_id(client: Any) -> str: + """Extract the service identifier from a botocore client instance.""" + try: + service_model = client._service_model + return service_model.endpoint_prefix + except AttributeError: + return "" + + +def _get_server_address(client: Any) -> str | None: + """Extract the server address (hostname) from the client's endpoint URL.""" + try: + endpoint_url = client._endpoint.host + parsed = urlparse(endpoint_url) + return parsed.hostname + except (AttributeError, ValueError): + return None + + +def _get_server_port(client: Any) -> int | None: + """Extract the server port from the client's endpoint URL if non-standard.""" + try: + endpoint_url = client._endpoint.host + parsed = urlparse(endpoint_url) + port = parsed.port + if port and port != 443 and port > 0: + return port + except (AttributeError, ValueError): + pass + return None + + +def _create_invocation( + handler: TelemetryHandler, + client: Any, + api_params: dict[str, Any], + capture_content: bool, +) -> InferenceInvocation: + """Create and configure an InferenceInvocation from Converse parameters.""" + model_id = api_params.get("modelId") or "" + server_address = _get_server_address(client) + server_port = _get_server_port(client) + + invocation = handler.start_inference( + provider=_PROVIDER, + request_model=model_id, + server_address=server_address, + server_port=server_port, + ) + + invocation.attributes = get_request_attributes(api_params) + + if capture_content: + invocation.input_messages = get_input_messages(api_params) + invocation.system_instruction = get_system_instruction(api_params) + + return invocation + + +def _set_response_attributes( + invocation: InferenceInvocation, + result: dict[str, Any], + capture_content: bool, +) -> None: + """Extract response attributes from a Converse result and apply them.""" + response_attrs = get_response_attributes(result) + + invocation.response_model_name = response_attrs.get("response_model") + invocation.response_id = response_attrs.get("response_id") + + finish_reasons = response_attrs.get("finish_reasons") + if finish_reasons: + invocation.finish_reasons = finish_reasons + + input_tokens = response_attrs.get("input_tokens") + if input_tokens is not None: + invocation.input_tokens = input_tokens + + output_tokens = response_attrs.get("output_tokens") + if output_tokens is not None: + invocation.output_tokens = output_tokens + + if capture_content: + invocation.output_messages = get_output_messages(result) diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/version.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/version.py new file mode 100644 index 00000000..cefb388f --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/version.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +__version__ = "1.0b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/wrappers.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/wrappers.py new file mode 100644 index 00000000..0dc3bb33 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/wrappers.py @@ -0,0 +1,223 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +# +# Based on the Bedrock extension in opentelemetry-python-contrib by @xrmx: +# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3161 +# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3258 + +"""Stream wrappers for AWS Bedrock ConverseStream instrumentation.""" + +from __future__ import annotations + +from typing import Any + +from opentelemetry.util.genai.invocation import InferenceInvocation +from opentelemetry.util.genai.types import ( + MessagePart, + OutputMessage, + Text, + ToolCallRequest, +) + +from .extractors import normalize_finish_reason + + +class ConverseStreamWrapper: + """Wrapper around a Bedrock ConverseStream response that accumulates + chunks and finalizes the invocation when the stream completes. + + The Bedrock ConverseStream response has a ``stream`` key containing an + EventStream iterator. This wrapper proxies the original response dict + but replaces the ``stream`` value with an instrumented iterator. + """ + + def __init__( + self, + response: dict[str, Any], + invocation: InferenceInvocation, + capture_content: bool, + ) -> None: + self._response = response + self._invocation = invocation + self._capture_content = capture_content + self._finalized = False + + # Wrap the event stream iterator + original_stream = response.get("stream") + if original_stream is not None: + response["stream"] = _StreamEventIterator( + original_stream, + self._on_complete, + self._on_fail, + capture_content, + ) + + def __getitem__(self, key: str) -> Any: + return self._response[key] + + def __contains__(self, key: str) -> bool: + return key in self._response + + def get(self, key: str, default: Any = None) -> Any: + return self._response.get(key, default) + + def _on_complete( + self, + input_tokens: int | None, + output_tokens: int | None, + finish_reason: str | None, + parts: list[MessagePart], + ) -> None: + if self._finalized: + return + self._finalized = True + + if input_tokens is not None: + self._invocation.input_tokens = input_tokens + if output_tokens is not None: + self._invocation.output_tokens = output_tokens + + normalized = normalize_finish_reason(finish_reason) + if normalized: + self._invocation.finish_reasons = [normalized] + + if self._capture_content and parts: + self._invocation.output_messages = [ + OutputMessage( + role="assistant", + parts=parts, + finish_reason=normalized or "", + ) + ] + + self._invocation.stop() + + def _on_fail(self, exc: BaseException) -> None: + if self._finalized: + return + self._finalized = True + self._invocation.fail(exc) + + +class _StreamEventIterator: + """Iterates over Bedrock ConverseStream events, accumulating content.""" + + def __init__( + self, + event_stream: Any, + on_complete: Any, + on_fail: Any, + capture_content: bool, + ) -> None: + self._event_stream = event_stream + self._on_complete = on_complete + self._on_fail = on_fail + self._capture_content = capture_content + + # Accumulated state + self._input_tokens: int | None = None + self._output_tokens: int | None = None + self._finish_reason: str | None = None + self._parts: list[MessagePart] = [] + self._current_text: str = "" + self._current_tool_use: dict[str, Any] | None = None + + def __iter__(self) -> _StreamEventIterator: + return self + + def __next__(self) -> Any: + try: + event = next(self._event_stream) + except StopIteration: + self._finalize_current_block() + self._on_complete( + self._input_tokens, + self._output_tokens, + self._finish_reason, + self._parts, + ) + raise + except Exception as exc: + self._on_fail(exc) + raise + + self._process_event(event) + return event + + def _process_event(self, event: dict[str, Any]) -> None: + """Process a single stream event and update accumulated state.""" + if "contentBlockStart" in event: + self._handle_content_block_start(event["contentBlockStart"]) + elif "contentBlockDelta" in event: + self._handle_content_block_delta(event["contentBlockDelta"]) + elif "contentBlockStop" in event: + self._finalize_current_block() + elif "messageStop" in event: + stop = event["messageStop"] + self._finish_reason = stop.get("stopReason") + elif "metadata" in event: + metadata = event["metadata"] + usage = metadata.get("usage") or {} + input_tokens = usage.get("inputTokens") + output_tokens = usage.get("outputTokens") + if input_tokens is not None: + self._input_tokens = input_tokens + if output_tokens is not None: + self._output_tokens = output_tokens + + def _handle_content_block_start(self, block_start: dict[str, Any]) -> None: + """Handle the start of a new content block.""" + self._finalize_current_block() + start = block_start.get("start") or {} + if "toolUse" in start: + tool_use = start["toolUse"] + self._current_tool_use = { + "toolUseId": tool_use.get("toolUseId"), + "name": tool_use.get("name", ""), + "input_json": "", + } + else: + # Text block (default) + self._current_text = "" + + def _handle_content_block_delta(self, block_delta: dict[str, Any]) -> None: + """Handle a content block delta.""" + delta = block_delta.get("delta") or {} + if "text" in delta: + if self._capture_content: + self._current_text += delta["text"] + elif "toolUse" in delta: + if self._current_tool_use is not None and self._capture_content: + tool_delta = delta["toolUse"] + self._current_tool_use["input_json"] += tool_delta.get( + "input", "" + ) + + def _finalize_current_block(self) -> None: + """Finalize the current content block and add it to parts.""" + if not self._capture_content: + self._current_text = "" + self._current_tool_use = None + return + + if self._current_tool_use is not None: + import json # pylint: disable=import-outside-toplevel # noqa: PLC0415 + + input_json = self._current_tool_use.get("input_json", "") + arguments: Any = None + if input_json: + try: + arguments = json.loads(input_json) + except ValueError: + arguments = input_json + self._parts.append( + ToolCallRequest( + arguments=arguments, + name=self._current_tool_use.get("name", ""), + id=self._current_tool_use.get("toolUseId"), + ) + ) + self._current_tool_use = None + elif self._current_text: + self._parts.append(Text(content=self._current_text)) + self._current_text = "" diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/__init__.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/__init__.py new file mode 100644 index 00000000..e57cf4ab --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/conftest.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/conftest.py new file mode 100644 index 00000000..5c38c800 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/conftest.py @@ -0,0 +1,75 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Test configuration and fixtures for AWS Bedrock instrumentation tests.""" +# pylint: disable=redefined-outer-name + +from __future__ import annotations + +import os + +import pytest + +from opentelemetry.instrumentation.genai.bedrock import BedrockInstrumentor +from opentelemetry.test_util_genai.instrumentor import instrument +from opentelemetry.test_util_genai.vcr import scrub_response_headers + +pytest_plugins = [ + "opentelemetry.test_util_genai.fixtures", + "opentelemetry.test_util_genai.vcr", +] + + +@pytest.fixture(autouse=True) +def environment(): + """Set up environment variables for testing.""" + if not os.getenv("AWS_ACCESS_KEY_ID"): + os.environ["AWS_ACCESS_KEY_ID"] = "test_access_key_id" + if not os.getenv("AWS_SECRET_ACCESS_KEY"): + os.environ["AWS_SECRET_ACCESS_KEY"] = "test_secret_access_key" + if not os.getenv("AWS_DEFAULT_REGION"): + os.environ["AWS_DEFAULT_REGION"] = "us-east-1" + + +@pytest.fixture(scope="module") +def vcr_config(): + """Configure VCR for recording/replaying HTTP interactions.""" + return { + "filter_headers": [ + ("authorization", "REDACTED"), + ("x-amz-security-token", "REDACTED"), + ("x-amz-date", "REDACTED"), + ], + "decode_compressed_response": True, + "before_record_response": scrub_response_headers( + ["x-amzn-requestid", "set-cookie"] + ), + } + + +@pytest.fixture +def instrument_no_content(tracer_provider, logger_provider, meter_provider): + """Instrument Bedrock without content capture (stable semconv mode).""" + with instrument( + BedrockInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + semconv="stable", + content_capture="NO_CONTENT", + ) as instrumentor: + yield instrumentor + + +@pytest.fixture +def instrument_with_content(tracer_provider, logger_provider, meter_provider): + """Instrument Bedrock with ``SPAN_ONLY`` content capture (experimental semconv).""" + with instrument( + BedrockInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + semconv="gen_ai_latest_experimental", + content_capture="SPAN_ONLY", + ) as instrumentor: + yield instrumentor diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.latest.txt b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.latest.txt new file mode 100644 index 00000000..1a524f07 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.latest.txt @@ -0,0 +1,46 @@ +# 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. + + +# ******************************** +# WARNING: NOT HERMETIC !!!!!!!!!! +# ******************************** +# +# This "requirements.txt" is installed in conjunction +# with multiple other dependencies in the top-level "tox.ini" +# file. In particular, please see: +# +# bedrock-latest: {[testenv]test_deps} +# bedrock-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.latest.txt +# +# This provides additional dependencies, namely: +# +# opentelemetry-api +# opentelemetry-sdk +# opentelemetry-semantic-conventions +# opentelemetry-instrumentation +# +# ... with a "dev" version based on the latest distribution. + + +# This variant of the requirements aims to test the system using +# the newest supported version of external dependencies. + +boto3 +botocore +wrapt==2.2.1 +# test with the latest version of opentelemetry-api, sdk, semantic conventions, and instrumentation + +-e util/opentelemetry-util-genai +-e instrumentation/opentelemetry-instrumentation-genai-bedrock diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.oldest.txt new file mode 100644 index 00000000..08230698 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.oldest.txt @@ -0,0 +1,27 @@ +# 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. + +# This variant of the requirements aims to test the system using +# the oldest supported version of external dependencies. + +boto3==1.35.0 +botocore==1.35.0 +wrapt==1.16.0 +opentelemetry-api==1.40 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.40 # when updating, also update in pyproject.toml +opentelemetry-semantic-conventions==0.61b0 # when updating, also update in pyproject.toml +opentelemetry-instrumentation==0.61b0 # when updating, also update in pyproject.toml + +-e util/opentelemetry-util-genai # TODO update to 1.0b0 when released +-e instrumentation/opentelemetry-instrumentation-genai-bedrock diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py new file mode 100644 index 00000000..c1acff0b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py @@ -0,0 +1,124 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Bedrock Converse instrumentation.""" + +from __future__ import annotations + +import boto3 +import pytest +from botocore.stub import Stubber + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) +from opentelemetry.semconv._incubating.attributes import ( + server_attributes as ServerAttributes, +) + + +@pytest.fixture +def bedrock_client(): + """Create a real botocore bedrock-runtime client.""" + return boto3.client("bedrock-runtime", region_name="us-east-1") + + +def _converse_response(): + """Build a Converse API response for the Stubber.""" + return { + "output": { + "message": { + "role": "assistant", + "content": [{"text": "Hello!"}], + } + }, + "stopReason": "end_turn", + "usage": { + "inputTokens": 10, + "outputTokens": 5, + "totalTokens": 15, + }, + "ResponseMetadata": { + "RequestId": "test-request-id", + "HTTPStatusCode": 200, + "HTTPHeaders": {}, + "RetryAttempts": 0, + }, + "metrics": {"latencyMs": 100}, + } + + +class TestConverseNoContent: + """Test Converse instrumentation without content capture.""" + + def test_basic_span_attributes( + self, instrument_no_content, bedrock_client, span_exporter + ): + """Test that basic span attributes are set correctly.""" + with Stubber(bedrock_client) as stubber: + stubber.add_response("converse", _converse_response()) + bedrock_client.converse( + modelId="anthropic.claude-3-5-sonnet-20241022-v2:0", + messages=[{"role": "user", "content": [{"text": "Hello!"}]}], + ) + + span_list = span_exporter.get_finished_spans() + assert len(span_list) == 1 + span = span_list[0] + + assert span.name == "chat anthropic.claude-3-5-sonnet-20241022-v2:0" + assert span.attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] == "chat" + assert span.attributes[GenAIAttributes.GEN_AI_SYSTEM] == "aws.bedrock" + assert ( + span.attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL] + == "anthropic.claude-3-5-sonnet-20241022-v2:0" + ) + assert span.attributes[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] == 10 + assert span.attributes[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] == 5 + assert span.attributes[ + GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS + ] == ("stop",) + assert ServerAttributes.SERVER_ADDRESS in span.attributes + + def test_error_records_exception( + self, instrument_no_content, bedrock_client, span_exporter + ): + """Test that exceptions are recorded on the span.""" + from botocore.exceptions import ( # noqa: PLC0415 + ClientError, + ) + + with Stubber(bedrock_client) as stubber: + stubber.add_client_error( + "converse", + service_error_code="ThrottlingException", + service_message="Rate exceeded", + ) + with pytest.raises(ClientError): + bedrock_client.converse( + modelId="anthropic.claude-3-5-sonnet-20241022-v2:0", + messages=[{"role": "user", "content": [{"text": "Hi"}]}], + ) + + span_list = span_exporter.get_finished_spans() + assert len(span_list) == 1 + span = span_list[0] + assert span.status.is_ok is False + + +class TestConverseWithContent: + """Test Converse instrumentation with content capture.""" + + def test_captures_output_text( + self, instrument_with_content, bedrock_client, span_exporter + ): + """Test that output message content is captured.""" + with Stubber(bedrock_client) as stubber: + stubber.add_response("converse", _converse_response()) + bedrock_client.converse( + modelId="anthropic.claude-3-5-sonnet-20241022-v2:0", + messages=[{"role": "user", "content": [{"text": "Say hi"}]}], + ) + + span_list = span_exporter.get_finished_spans() + assert len(span_list) == 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_instrumentor.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_instrumentor.py new file mode 100644 index 00000000..2e8bf479 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_instrumentor.py @@ -0,0 +1,46 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the BedrockInstrumentor class.""" + +from opentelemetry.instrumentation.genai.bedrock import BedrockInstrumentor + + +def test_instrumentor_instantiation(): + """Test that the instrumentor can be instantiated.""" + instrumentor = BedrockInstrumentor() + assert instrumentor is not None + assert isinstance(instrumentor, BedrockInstrumentor) + + +def test_instrumentation_dependencies(): + """Test that instrumentation dependencies are correctly reported.""" + instrumentor = BedrockInstrumentor() + dependencies = instrumentor.instrumentation_dependencies() + + assert dependencies is not None + assert len(dependencies) > 0 + assert "botocore >= 1.35.0" in dependencies + + +def test_instrument_uninstrument_cycle( + tracer_provider, logger_provider, meter_provider +): + """Test that instrument() and uninstrument() can be called.""" + instrumentor = BedrockInstrumentor() + + instrumentor.instrument( + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + ) + + instrumentor.uninstrument() + + instrumentor.instrument( + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + ) + + instrumentor.uninstrument() diff --git a/pyproject.toml b/pyproject.toml index 537181ee..f9bcc356 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "opentelemetry-instrumentation-genai-openai[instruments]", "opentelemetry-instrumentation-genai-openai-agents[instruments]", "opentelemetry-instrumentation-genai-weaviate-client[instruments]", + "opentelemetry-instrumentation-genai-bedrock[instruments]", ] @@ -37,6 +38,7 @@ opentelemetry-instrumentation-genai-langchain = { workspace = true } opentelemetry-instrumentation-genai-openai = { workspace = true } opentelemetry-instrumentation-genai-openai-agents = { workspace = true } opentelemetry-instrumentation-genai-weaviate-client = { workspace = true } +opentelemetry-instrumentation-genai-bedrock = { workspace = true } # https://docs.astral.sh/uv/reference/settings/#workspace diff --git a/tox.ini b/tox.ini index 07a34e4e..9f415d9e 100644 --- a/tox.ini +++ b/tox.ini @@ -40,6 +40,10 @@ envlist = py3{12,13}-test-instrumentation-genai-langchain-conformance lint-instrumentation-genai-langchain + ; instrumentation-genai-bedrock + py3{10,11,12,13,14}-test-instrumentation-genai-bedrock-{oldest,latest} + lint-instrumentation-genai-bedrock + ; instrumentation-genai-weaviate-client ; TODO: write tests (tests/ is empty), add tests/requirements.{oldest,latest}.txt ; and weaviate-{oldest,latest} factors below. @@ -117,6 +121,13 @@ deps = claude-agent-sdk-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk/tests/requirements.latest.txt lint-instrumentation-genai-claude-agent-sdk: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk/tests/requirements.oldest.txt + bedrock-oldest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.oldest.txt + bedrock-oldest: {[testenv]pytest_deps} + bedrock-latest: {[testenv]test_deps} + bedrock-latest: {[testenv]pytest_deps} + bedrock-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.latest.txt + lint-instrumentation-genai-bedrock: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.oldest.txt + ; Langchain unit tests: single pinned requirements until oldest/latest is defined. langchain: {[testenv]test_deps} langchain: {[testenv]pytest_deps} @@ -169,6 +180,9 @@ commands = test-instrumentation-genai-langchain-conformance: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_conformance.py --vcr-record=none {posargs} lint-instrumentation-genai-langchain: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-langchain" + test-instrumentation-genai-bedrock-{oldest,latest}: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests --vcr-record=none {posargs} + lint-instrumentation-genai-bedrock: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-bedrock" + lint-instrumentation-genai-weaviate-client: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-weaviate-client" test-util-genai: pytest {toxinidir}/util/opentelemetry-util-genai/tests {posargs} diff --git a/uv.lock b/uv.lock index 871731a0..3b1dbe1c 100644 --- a/uv.lock +++ b/uv.lock @@ -5,6 +5,7 @@ requires-python = ">=3.10" [manifest] members = [ "opentelemetry-instrumentation-genai-anthropic", + "opentelemetry-instrumentation-genai-bedrock", "opentelemetry-instrumentation-genai-claude-agent-sdk", "opentelemetry-instrumentation-genai-langchain", "opentelemetry-instrumentation-genai-openai", @@ -92,6 +93,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] +[[package]] +name = "botocore" +version = "1.43.14" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/3c/798d2f7deb118241930c7c6bcfb0b970d3f0245bf580700663199aeed2c3/botocore-1.43.14.tar.gz", hash = "sha256:b9e500737e43d2f147c9d4e23b54360335e77d4c0ba90a318f51b65e06cb8516", size = 15382604, upload-time = "2026-05-22T19:28:36.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/7e/6e64821077cd2efc4aa51b7d638fb6d48e1c7c450201c529fbaf1de8bfd3/botocore-1.43.14-py3-none-any.whl", hash = "sha256:1f4a2a95ea78c10398e78431e98c1fe47adb54a7b10a32975144c1f541186658", size = 15061424, upload-time = "2026-05-22T19:28:32.682Z" }, +] + [[package]] name = "cachetools" version = "7.1.4" @@ -760,6 +775,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "joserfc" version = "1.6.5" @@ -1151,6 +1175,31 @@ requires-dist = [ ] provides-extras = ["instruments"] +[[package]] +name = "opentelemetry-instrumentation-genai-bedrock" +source = { editable = "instrumentation/opentelemetry-instrumentation-genai-bedrock" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-genai" }, +] + +[package.optional-dependencies] +instruments = [ + { name = "botocore" }, +] + +[package.metadata] +requires-dist = [ + { name = "botocore", marker = "extra == 'instruments'", specifier = ">=1.35.0" }, + { name = "opentelemetry-api", specifier = "~=1.40" }, + { name = "opentelemetry-instrumentation", specifier = "~=0.61b0" }, + { name = "opentelemetry-semantic-conventions", specifier = "~=0.61b0" }, + { name = "opentelemetry-util-genai", editable = "util/opentelemetry-util-genai" }, +] +provides-extras = ["instruments"] + [[package]] name = "opentelemetry-instrumentation-genai-claude-agent-sdk" source = { editable = "instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk" } @@ -1303,6 +1352,7 @@ dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-instrumentation-genai-anthropic", extra = ["instruments"] }, + { name = "opentelemetry-instrumentation-genai-bedrock", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-genai-claude-agent-sdk", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-genai-langchain", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-genai-openai", extra = ["instruments"] }, @@ -1329,6 +1379,7 @@ requires-dist = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-instrumentation-genai-anthropic", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-genai-anthropic" }, + { name = "opentelemetry-instrumentation-genai-bedrock", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-genai-bedrock" }, { name = "opentelemetry-instrumentation-genai-claude-agent-sdk", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk" }, { name = "opentelemetry-instrumentation-genai-langchain", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-genai-langchain" }, { name = "opentelemetry-instrumentation-genai-openai", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-genai-openai" }, @@ -1875,6 +1926,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-discovery" version = "1.3.1" @@ -2155,6 +2218,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1"