diff --git a/.changelog/5266.deprecated b/.changelog/5266.deprecated
new file mode 100644
index 00000000000..42e0816ea06
--- /dev/null
+++ b/.changelog/5266.deprecated
@@ -0,0 +1 @@
+`opentelemetry-instrumentation-botocore`: deprecate Bedrock runtime extension in favor of `opentelemetry-instrumentation-genai-bedrock`
diff --git a/instrumentation/opentelemetry-instrumentation-botocore/README.rst b/instrumentation/opentelemetry-instrumentation-botocore/README.rst
index 61804f740bb..fa0001360f6 100644
--- a/instrumentation/opentelemetry-instrumentation-botocore/README.rst
+++ b/instrumentation/opentelemetry-instrumentation-botocore/README.rst
@@ -14,14 +14,20 @@ Extensions
The instrumentation supports creating extensions for AWS services for enriching what is collected. We have extensions
for the following AWS services:
-- Bedrock Runtime (botocore only)
+- Bedrock Runtime (botocore only) [Deprecated]
- DynamoDB
- Lambda
- SNS
- SQS
-Bedrock Runtime (botocore only)
-*******************************
+Bedrock Runtime (botocore only) [Deprecated]
+********************************************
+
+.. note::
+ The Bedrock extension in ``opentelemetry-instrumentation-botocore`` is deprecated
+ and will be removed in a future release. Please use `opentelemetry-instrumentation-genai-bedrock `_.
+ When ``opentelemetry-instrumentation-genai-bedrock`` is active, this extension
+ is automatically suppressed to avoid duplicate telemetry.
This extension implements the GenAI semantic conventions for the following API calls:
diff --git a/instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/extensions/bedrock.py b/instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/extensions/bedrock.py
index 61c54d629d2..a60a1e4c7f5 100644
--- a/instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/extensions/bedrock.py
+++ b/instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/extensions/bedrock.py
@@ -8,9 +8,11 @@
from __future__ import annotations
+import importlib.metadata
import io
import json
import logging
+import warnings
from timeit import default_timer
from typing import Any
@@ -28,6 +30,7 @@
)
from opentelemetry.instrumentation.botocore.extensions.types import (
_AttributeMapT,
+ _AwsSdkCallContext,
_AwsSdkExtension,
_BotoClientErrorT,
_BotocoreInstrumentorContext,
@@ -102,6 +105,14 @@
_MODEL_ID_KEY: str = "modelId"
+def _is_genai_bedrock_active() -> bool:
+ try:
+ importlib.metadata.version("opentelemetry-instrumentation-genai-bedrock")
+ return True
+ except importlib.metadata.PackageNotFoundError:
+ return False
+
+
class _BedrockRuntimeExtension(_AwsSdkExtension):
"""
This class is an extension for bool:
+ return not self._handled_operations
+
def should_end_span_on_exit(self):
- return self._call_context.operation not in self._DONT_CLOSE_SPAN_ON_END_OPERATIONS
+ return self._call_context.operation not in self._dont_close_span_on_end_operations
def setup_metrics(self, meter: Meter, metrics: dict[str, Instrument]):
+ if self._disabled:
+ return
metrics[GEN_AI_CLIENT_OPERATION_DURATION] = meter.create_histogram(
name=GEN_AI_CLIENT_OPERATION_DURATION,
description="GenAI operation duration",
@@ -158,7 +195,7 @@ def _extract_metrics_attributes(self) -> _AttributeMapT:
return attributes
def extract_attributes(self, attributes: _AttributeMapT):
- if self._call_context.operation not in self._HANDLED_OPERATIONS:
+ if self._call_context.operation not in self._handled_operations:
return
attributes[GEN_AI_SYSTEM] = GenAiSystemValues.AWS_BEDROCK.value
@@ -376,7 +413,7 @@ def _get_messages_from_input_text(self, decoded_body: dict[str, Any], input_name
return []
def before_service_call(self, span: Span, instrumentor_context: _BotocoreInstrumentorContext):
- if self._call_context.operation not in self._HANDLED_OPERATIONS:
+ if self._call_context.operation not in self._handled_operations:
return
capture_content = genai_capture_message_content()
@@ -540,7 +577,7 @@ def on_success(
result: dict[str, Any],
instrumentor_context: _BotocoreInstrumentorContext,
):
- if self._call_context.operation not in self._HANDLED_OPERATIONS:
+ if self._call_context.operation not in self._handled_operations:
return
capture_content = genai_capture_message_content()
@@ -836,7 +873,7 @@ def on_error(
exception: _BotoClientErrorT,
instrumentor_context: _BotocoreInstrumentorContext,
):
- if self._call_context.operation not in self._HANDLED_OPERATIONS:
+ if self._call_context.operation not in self._handled_operations:
return
span.set_status(Status(StatusCode.ERROR, str(exception)))
diff --git a/instrumentation/opentelemetry-instrumentation-botocore/tests/cassettes/test_converse_suppressed_end_to_end_when_genai_active.yaml b/instrumentation/opentelemetry-instrumentation-botocore/tests/cassettes/test_converse_suppressed_end_to_end_when_genai_active.yaml
new file mode 100644
index 00000000000..255c3832b76
--- /dev/null
+++ b/instrumentation/opentelemetry-instrumentation-botocore/tests/cassettes/test_converse_suppressed_end_to_end_when_genai_active.yaml
@@ -0,0 +1,81 @@
+interactions:
+- request:
+ body: |-
+ {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "text": "Say this is a test"
+ }
+ ]
+ }
+ ],
+ "inferenceConfig": {
+ "maxTokens": 10,
+ "temperature": 0.8,
+ "topP": 1,
+ "stopSequences": [
+ "|"
+ ]
+ }
+ }
+ headers:
+ Content-Length:
+ - '170'
+ Content-Type:
+ - application/json
+ User-Agent:
+ - Boto3/1.35.56 md/Botocore#1.35.56 ua/2.0 os/macos#24.0.0 md/arch#arm64 lang/python#3.13.1
+ md/pyimpl#CPython cfg/retry-mode#legacy Botocore/1.35.56
+ X-Amz-Date:
+ - 20250206T051231Z
+ X-Amzn-Trace-Id:
+ - Root=1-c114b02b-84cec2d763a71299ae73fb9f;Parent=6f540fd45e1dc3b3;Sampled=1
+ amz-sdk-invocation-id:
+ - a8c71d0c-ae7b-4685-b696-6ba0ded398ef
+ amz-sdk-request:
+ - attempt=1
+ authorization:
+ - Bearer test_aws_authorization
+ method: POST
+ uri: https://bedrock-runtime.us-east-1.amazonaws.com/model/amazon.titan-text-lite-v1/converse
+ response:
+ body:
+ string: |-
+ {
+ "metrics": {
+ "latencyMs": 608
+ },
+ "output": {
+ "message": {
+ "content": [
+ {
+ "text": "Hi, how can I help you"
+ }
+ ],
+ "role": "assistant"
+ }
+ },
+ "stopReason": "max_tokens",
+ "usage": {
+ "inputTokens": 8,
+ "outputTokens": 10,
+ "totalTokens": 18
+ }
+ }
+ headers:
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Thu, 06 Feb 2025 05:12:33 GMT
+ Set-Cookie: test_set_cookie
+ x-amzn-RequestId:
+ - 180bdcbc-0fea-4b99-a0f0-b858dd830a83
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/instrumentation/opentelemetry-instrumentation-botocore/tests/test_botocore_bedrock.py b/instrumentation/opentelemetry-instrumentation-botocore/tests/test_botocore_bedrock.py
index a8c0064d1dc..480dc4be863 100644
--- a/instrumentation/opentelemetry-instrumentation-botocore/tests/test_botocore_bedrock.py
+++ b/instrumentation/opentelemetry-instrumentation-botocore/tests/test_botocore_bedrock.py
@@ -5,8 +5,11 @@
from __future__ import annotations
+import importlib.metadata
import io
import json
+import warnings
+from typing import Any
from unittest import mock
import boto3
@@ -14,15 +17,23 @@
from botocore.eventstream import EventStream, EventStreamError
from botocore.response import StreamingBody
+from opentelemetry.instrumentation.botocore.extensions.bedrock import (
+ _BedrockRuntimeExtension,
+ _is_genai_bedrock_active,
+)
from opentelemetry.instrumentation.botocore.extensions.bedrock_utils import (
ConverseStreamWrapper,
InvokeModelWithResponseStreamWrapper,
_Choice,
)
+from opentelemetry.instrumentation.botocore.extensions.types import (
+ _AwsSdkCallContext,
+)
from opentelemetry.semconv._incubating.attributes.error_attributes import (
ERROR_TYPE,
)
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import (
+ GEN_AI_SYSTEM,
GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
)
@@ -3076,3 +3087,126 @@ def get_anthropic_tool_config():
},
}
]
+
+
+def _create_mock_call_context(
+ operation: str = "Converse",
+ params: dict[str, Any] | None = None,
+) -> _AwsSdkCallContext:
+ mock_client = mock.MagicMock()
+ mock_client.meta.region_name = "us-east-1"
+ mock_client.meta.endpoint_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
+ mock_client.meta.service_model.service_name = "bedrock-runtime"
+ mock_client.meta.service_model.service_id = "Bedrock Runtime"
+ mock_client.meta.service_model.api_version = "2023-09-30"
+ return _AwsSdkCallContext(mock_client, (operation, params or {}))
+
+
+def test_bedrock_runtime_extension_deprecation_warning():
+ call_context = _create_mock_call_context()
+ with pytest.deprecated_call() as warning_info:
+ _BedrockRuntimeExtension(call_context)
+
+ assert any(
+ "The Bedrock extension in 'opentelemetry-instrumentation-botocore' is deprecated" in str(w.message)
+ for w in warning_info
+ )
+
+
+def test_is_genai_bedrock_active():
+ with mock.patch(
+ "importlib.metadata.version",
+ side_effect=importlib.metadata.PackageNotFoundError,
+ ):
+ assert not _is_genai_bedrock_active()
+
+ with mock.patch("importlib.metadata.version", return_value="0.1.0"):
+ assert _is_genai_bedrock_active()
+
+
+def test_bedrock_extension_suppressed_when_genai_active():
+ call_context = _create_mock_call_context(
+ operation="ConverseStream",
+ params={"modelId": "amazon.titan-text-lite-v1"},
+ )
+ with (
+ mock.patch(
+ "opentelemetry.instrumentation.botocore.extensions.bedrock._is_genai_bedrock_active",
+ return_value=True,
+ ),
+ warnings.catch_warnings(record=True) as recorded_warnings,
+ ):
+ warnings.simplefilter("always", DeprecationWarning)
+ extension = _BedrockRuntimeExtension(call_context)
+
+ assert not any(issubclass(w.category, DeprecationWarning) for w in recorded_warnings)
+ assert extension._disabled is True
+ # When disabled, should_end_span_on_exit should be True (standard botocore behavior)
+ assert extension.should_end_span_on_exit() is True
+
+ # setup_metrics should be a no-op
+ metrics: dict[str, Any] = {}
+ extension.setup_metrics(mock.MagicMock(), metrics)
+ assert len(metrics) == 0
+
+ # extract_attributes should be a no-op
+ attributes: dict[str, Any] = {"rpc.system": "aws-api"}
+ extension.extract_attributes(attributes)
+ assert attributes == {"rpc.system": "aws-api"}
+
+ # before_service_call should be a no-op
+ mock_span = mock.MagicMock()
+ mock_ctx = mock.MagicMock()
+ extension.before_service_call(mock_span, mock_ctx)
+ mock_ctx.logger.emit.assert_not_called()
+ mock_span.update_name.assert_not_called()
+
+ # on_success should be a no-op (no stream wrapper, no events, no metrics)
+ result = {"stream": mock.MagicMock(spec=EventStream)}
+ extension.on_success(mock_span, result, mock_ctx)
+ mock_ctx.logger.emit.assert_not_called()
+ assert not isinstance(result["stream"], ConverseStreamWrapper)
+
+ # on_error should be a no-op
+ mock_error = mock.MagicMock()
+ extension.on_error(mock_span, mock_error, mock_ctx)
+ mock_span.set_status.assert_not_called()
+
+
+@pytest.mark.skipif(BOTO3_VERSION < (1, 35, 56), reason="Converse API not available")
+@pytest.mark.vcr()
+def test_converse_suppressed_end_to_end_when_genai_active(
+ span_exporter,
+ log_exporter,
+ metric_reader,
+ bedrock_runtime_client,
+ instrument_with_content,
+):
+ with mock.patch(
+ "opentelemetry.instrumentation.botocore.extensions.bedrock._is_genai_bedrock_active",
+ return_value=True,
+ ):
+ messages = [{"role": "user", "content": [{"text": "Say this is a test"}]}]
+ response = bedrock_runtime_client.converse(
+ messages=messages,
+ modelId="amazon.titan-text-lite-v1",
+ inferenceConfig={
+ "maxTokens": 10,
+ "temperature": 0.8,
+ "topP": 1,
+ "stopSequences": ["|"],
+ },
+ )
+ assert response is not None
+
+ (span,) = span_exporter.get_finished_spans()
+ # Span name should remain the raw botocore RPC span name
+ assert span.name == "Bedrock Runtime.Converse"
+ assert GEN_AI_SYSTEM not in span.attributes
+ assert "gen_ai.request.model" not in span.attributes
+
+ # No gen_ai log events emitted
+ assert len(log_exporter.get_finished_logs()) == 0
+
+ # No gen_ai metrics recorded
+ assert metric_reader.get_metrics_data() is None