From 3db4be03fc13ae6d5f5f9cff7b50c9debffc853f Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Tue, 26 May 2026 17:21:22 +0000 Subject: [PATCH 1/6] feat(bedrock): Add initial scaffold for Bedrock Runtime instrumentation Scaffold the opentelemetry-instrumentation-genai-bedrock package to instrument the botocore Bedrock Runtime Converse and ConverseStream APIs using the TelemetryHandler/InferenceInvocation lifecycle from opentelemetry-util-genai. The patch wraps botocore.client.BaseClient._make_api_call, filtering for bedrock-runtime service calls to Converse/ConverseStream operations. Assisted-by: Claude Opus 4.6 --- .../.changelog/.gitignore | 1 + .../CHANGELOG.md | 16 ++ .../LICENSE | 202 ++++++++++++++++ .../README.rst | 63 +++++ .../pyproject.toml | 91 ++++++++ .../instrumentation/genai/bedrock/__init__.py | 101 ++++++++ .../genai/bedrock/extractors.py | 187 +++++++++++++++ .../instrumentation/genai/bedrock/package.py | 4 + .../instrumentation/genai/bedrock/patch.py | 162 +++++++++++++ .../instrumentation/genai/bedrock/version.py | 4 + .../instrumentation/genai/bedrock/wrappers.py | 218 ++++++++++++++++++ .../tests/__init__.py | 2 + .../tests/conftest.py | 75 ++++++ .../tests/requirements.latest.txt | 45 ++++ .../tests/requirements.oldest.txt | 26 +++ 15 files changed, 1197 insertions(+) create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/.changelog/.gitignore create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/CHANGELOG.md create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/LICENSE create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/README.rst create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/pyproject.toml create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/__init__.py create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/extractors.py create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/package.py create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/patch.py create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/version.py create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/wrappers.py create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/__init__.py create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/conftest.py create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.latest.txt create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.oldest.txt 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.md b/instrumentation/opentelemetry-instrumentation-genai-bedrock/CHANGELOG.md new file mode 100644 index 00000000..54bfa4a7 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/CHANGELOG.md @@ -0,0 +1,16 @@ +# 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). + + + + 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..426c6d42 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/__init__.py @@ -0,0 +1,101 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +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..268c41a9 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/extractors.py @@ -0,0 +1,187 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""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.semconv._incubating.attributes import ( + server_attributes as ServerAttributes, +) +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: "aws.bedrock", + 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..08fdc366 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/patch.py @@ -0,0 +1,162 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Patching functions for AWS Bedrock instrumentation.""" + +from __future__ import annotations + +import logging +from typing import Any, Callable +from urllib.parse import urlparse + +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 + +_logger = logging.getLogger(__name__) + +_SUPPORTED_OPERATIONS = frozenset({"Converse", "ConverseStream"}) +_BEDROCK_RUNTIME_SERVICE = "bedrock-runtime" +_PROVIDER = "aws.bedrock" + + +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..5f96c4fc --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/wrappers.py @@ -0,0 +1,218 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""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..4e4cf94b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.latest.txt @@ -0,0 +1,45 @@ +# 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. + +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..fdf6ae32 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.oldest.txt @@ -0,0 +1,26 @@ +# 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. + +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 From 590f51739dfb3b818c0082a5fb45b0632676fef5 Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Tue, 26 May 2026 17:28:13 +0000 Subject: [PATCH 2/6] feat(bedrock): Integrate package into workspace and add tests Register the bedrock package in the root pyproject.toml workspace, add tox test environments, and include basic tests validating span attributes, error recording, and content capture via botocore Stubber. Assisted-by: Claude Opus 4.6 --- .../genai/bedrock/extractors.py | 3 - .../tests/test_converse.py | 146 ++++++++++++++++++ .../tests/test_instrumentor.py | 46 ++++++ pyproject.toml | 2 + tox.ini | 14 ++ 5 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_instrumentor.py 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 index 268c41a9..c4784741 100644 --- 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 @@ -10,9 +10,6 @@ from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, ) -from opentelemetry.semconv._incubating.attributes import ( - server_attributes as ServerAttributes, -) from opentelemetry.util.genai.types import ( InputMessage, MessagePart, 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..bbdb7b7a --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py @@ -0,0 +1,146 @@ +# 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} From 4d4b216fc69c2bb06770e750c4479ea330d6bdcb Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Tue, 26 May 2026 17:45:52 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix(bedrock):=20Fix=20CI=20failures=20?= =?UTF-8?q?=E2=80=94=20add=20boto3=20dep,=20format,=20lock,=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add boto3 to test requirements (tests use boto3.client directly) - Apply ruff format to wrappers.py and test_converse.py - Regenerate instrumentation/README.md via tox -e generate - Add changelog fragment for PR #93 - Update uv.lock to include bedrock package Assisted-by: Claude Opus 4.6 --- instrumentation/README.md | 1 + .../.changelog/93.added | 1 + .../instrumentation/genai/bedrock/wrappers.py | 9 +-- .../tests/requirements.latest.txt | 1 + .../tests/requirements.oldest.txt | 1 + .../tests/test_converse.py | 44 +++--------- uv.lock | 72 +++++++++++++++++++ 7 files changed, 92 insertions(+), 37 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/.changelog/93.added 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/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/src/opentelemetry/instrumentation/genai/bedrock/wrappers.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/wrappers.py index 5f96c4fc..3d97aaa9 100644 --- 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 @@ -42,7 +42,10 @@ def __init__( original_stream = response.get("stream") if original_stream is not None: response["stream"] = _StreamEventIterator( - original_stream, self._on_complete, self._on_fail, capture_content + original_stream, + self._on_complete, + self._on_fail, + capture_content, ) def __getitem__(self, key: str) -> Any: @@ -173,9 +176,7 @@ def _handle_content_block_start(self, block_start: dict[str, Any]) -> None: # Text block (default) self._current_text = "" - def _handle_content_block_delta( - self, block_delta: dict[str, Any] - ) -> None: + 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: diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.latest.txt b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.latest.txt index 4e4cf94b..1a524f07 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.latest.txt +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.latest.txt @@ -37,6 +37,7 @@ # 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 diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.oldest.txt index fdf6ae32..08230698 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/requirements.oldest.txt @@ -15,6 +15,7 @@ # 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 diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py index bbdb7b7a..c1acff0b 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py @@ -59,43 +59,25 @@ def test_basic_span_attributes( stubber.add_response("converse", _converse_response()) bedrock_client.converse( modelId="anthropic.claude-3-5-sonnet-20241022-v2:0", - messages=[ - {"role": "user", "content": [{"text": "Hello!"}]} - ], + 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.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 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( @@ -115,9 +97,7 @@ def test_error_records_exception( with pytest.raises(ClientError): bedrock_client.converse( modelId="anthropic.claude-3-5-sonnet-20241022-v2:0", - messages=[ - {"role": "user", "content": [{"text": "Hi"}]} - ], + messages=[{"role": "user", "content": [{"text": "Hi"}]}], ) span_list = span_exporter.get_finished_spans() @@ -137,9 +117,7 @@ def test_captures_output_text( 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"}]} - ], + messages=[{"role": "user", "content": [{"text": "Say hi"}]}], ) span_list = span_exporter.get_finished_spans() 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" From 41658cab8785565724027eeec7c2e06e2dc2181a Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Tue, 26 May 2026 17:57:16 +0000 Subject: [PATCH 4/6] fix(bedrock): Remove CHANGELOG.md to pass CI changelog check The changelog workflow rejects PRs that include any **/CHANGELOG.md in the diff. The .changelog/93.added fragment satisfies the requirement for a changelog entry. CHANGELOG.md will be generated by towncrier on first release. --- .../CHANGELOG.md | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 instrumentation/opentelemetry-instrumentation-genai-bedrock/CHANGELOG.md diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/CHANGELOG.md b/instrumentation/opentelemetry-instrumentation-genai-bedrock/CHANGELOG.md deleted file mode 100644 index 54bfa4a7..00000000 --- a/instrumentation/opentelemetry-instrumentation-genai-bedrock/CHANGELOG.md +++ /dev/null @@ -1,16 +0,0 @@ -# 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). - - - - From b451484f4367a8d286eb304c56372fc1e22df523 Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Tue, 26 May 2026 19:35:23 +0000 Subject: [PATCH 5/6] fix(bedrock): Use semconv enum for system attribute and remove dead code Use GenAiSystemValues.AWS_BEDROCK enum instead of string literal "aws.bedrock" per project semconv rules. Remove unused logging import and _logger definition from patch.py. Assisted-by: Claude Opus 4.6 --- .../instrumentation/genai/bedrock/extractors.py | 2 +- .../opentelemetry/instrumentation/genai/bedrock/patch.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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 index c4784741..8188e712 100644 --- 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 @@ -44,7 +44,7 @@ def get_request_attributes( attributes: dict[str, AttributeValue | None] = { GenAIAttributes.GEN_AI_OPERATION_NAME: GenAIAttributes.GenAiOperationNameValues.CHAT.value, - GenAIAttributes.GEN_AI_SYSTEM: "aws.bedrock", + 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" 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 index 08fdc366..3660758d 100644 --- 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 @@ -5,10 +5,12 @@ from __future__ import annotations -import logging 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 @@ -21,11 +23,9 @@ ) from .wrappers import ConverseStreamWrapper -_logger = logging.getLogger(__name__) - _SUPPORTED_OPERATIONS = frozenset({"Converse", "ConverseStream"}) _BEDROCK_RUNTIME_SERVICE = "bedrock-runtime" -_PROVIDER = "aws.bedrock" +_PROVIDER = GenAIAttributes.GenAiSystemValues.AWS_BEDROCK.value def make_api_call_wrapper( From 1335b9c4a8bc5a4af2dad28ca8b45409e009ef54 Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Tue, 26 May 2026 19:55:39 +0000 Subject: [PATCH 6/6] docs(bedrock): Credit @xrmx as original author in file headers Add attribution to the source modules crediting Riccardo Magliocchetti (@xrmx) as the original author of the Bedrock extension in opentelemetry-python-contrib, with links to the upstream PRs. Assisted-by: Claude Opus 4.6 --- .../opentelemetry/instrumentation/genai/bedrock/__init__.py | 4 ++++ .../opentelemetry/instrumentation/genai/bedrock/extractors.py | 4 ++++ .../src/opentelemetry/instrumentation/genai/bedrock/patch.py | 4 ++++ .../opentelemetry/instrumentation/genai/bedrock/wrappers.py | 4 ++++ 4 files changed, 16 insertions(+) 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 index 426c6d42..f5e81837 100644 --- 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 @@ -1,5 +1,9 @@ # 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 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 index 8188e712..ff98d49e 100644 --- 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 @@ -1,5 +1,9 @@ # 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.""" 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 index 3660758d..5895d163 100644 --- 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 @@ -1,5 +1,9 @@ # 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.""" 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 index 3d97aaa9..0dc3bb33 100644 --- 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 @@ -1,5 +1,9 @@ # 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."""