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