Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/5266.deprecated
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-instrumentation-botocore`: deprecate Bedrock runtime extension in favor of `opentelemetry-instrumentation-genai-bedrock`
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://pypi.org/project/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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -28,6 +30,7 @@
)
from opentelemetry.instrumentation.botocore.extensions.types import (
_AttributeMapT,
_AwsSdkCallContext,
_AwsSdkExtension,
_BotoClientErrorT,
_BotocoreInstrumentorContext,
Expand Down Expand Up @@ -102,6 +105,14 @@
_MODEL_ID_KEY: str = "modelId"


def _is_genai_bedrock_active() -> bool:
try:
Comment thread
DylanRussell marked this conversation as resolved.
Resolved
importlib.metadata.version("opentelemetry-instrumentation-genai-bedrock")
return True
except importlib.metadata.PackageNotFoundError:
return False


class _BedrockRuntimeExtension(_AwsSdkExtension):
"""
This class is an extension for <a
Expand All @@ -120,10 +131,36 @@ class _BedrockRuntimeExtension(_AwsSdkExtension):
"InvokeModelWithResponseStream",
}

def __init__(self, call_context: _AwsSdkCallContext):
super().__init__(call_context)
if _is_genai_bedrock_active():
_logger.debug(
"opentelemetry-instrumentation-genai-bedrock is active; "
"suppressing botocore BedrockExtension to avoid duplicate spans."
)
self._handled_operations: set[str] = set()
self._dont_close_span_on_end_operations: set[str] = set()
else:
warnings.warn(
"The Bedrock extension in 'opentelemetry-instrumentation-botocore' is deprecated "
"and will be removed in a future release. "
"Please install 'opentelemetry-instrumentation-genai-bedrock' that would take over this extension.",
category=DeprecationWarning,
stacklevel=2,
)
self._handled_operations = self._HANDLED_OPERATIONS
self._dont_close_span_on_end_operations = self._DONT_CLOSE_SPAN_ON_END_OPERATIONS

@property
def _disabled(self) -> 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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)))
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,35 @@

from __future__ import annotations

import importlib.metadata
import io
import json
import warnings
from typing import Any
from unittest import mock

import boto3
import pytest
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,
)
Expand Down Expand Up @@ -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
Loading