Skip to content

Commit e6869cd

Browse files
SrdjanLLxrmx
andauthored
Capture server attributes for botocore API calls (#3448)
* Capture server attributes for botocore API calls * fix: use typing.Dict for 3.8 compatibility and resolve failing tests * Update changelog * Add server and port assertions for Bedrock API calls * Add server address and port to Bedrock metric attributes * Add license header and deferred type hint evaluation in utils.py * Refactor metric attributes creation and add linter suppression (too-many-lines) for bedrock.py --------- Co-authored-by: Riccardo Magliocchetti <[email protected]>
1 parent dec311f commit e6869cd

File tree

6 files changed

+120
-2
lines changed

6 files changed

+120
-2
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
([#3380](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3381))
2929
- `opentelemetry-instrumentation-[asynclick/click]` Add missing opentelemetry-instrumentation dep
3030
([#3447](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3447))
31+
- `opentelemetry-instrumentation-botocore` Capture server attributes for botocore API calls
32+
([#3448](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3448))
3133

3234
## Version 1.32.0/0.53b0 (2025-04-10)
3335

instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ def response_hook(span, service_name, operation_name, result):
9999
_BotocoreInstrumentorContext,
100100
)
101101
from opentelemetry.instrumentation.botocore.package import _instruments
102+
from opentelemetry.instrumentation.botocore.utils import get_server_attributes
102103
from opentelemetry.instrumentation.botocore.version import __version__
103104
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
104105
from opentelemetry.instrumentation.utils import (
@@ -277,6 +278,7 @@ def _patched_api_call(self, original_func, instance, args, kwargs):
277278
SpanAttributes.RPC_METHOD: call_context.operation,
278279
# TODO: update when semantic conventions exist
279280
"aws.region": call_context.region,
281+
**get_server_attributes(call_context.endpoint_url),
280282
}
281283

282284
_safe_invoke(extension.extract_attributes, attributes)

instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/extensions/bedrock.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# Includes work from:
1616
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
1717
# SPDX-License-Identifier: Apache-2.0
18+
# pylint: disable=too-many-lines
1819

1920
from __future__ import annotations
2021

@@ -41,6 +42,7 @@
4142
_BotoClientErrorT,
4243
_BotocoreInstrumentorContext,
4344
)
45+
from opentelemetry.instrumentation.botocore.utils import get_server_attributes
4446
from opentelemetry.metrics import Instrument, Meter
4547
from opentelemetry.semconv._incubating.attributes.error_attributes import (
4648
ERROR_TYPE,
@@ -146,7 +148,10 @@ def setup_metrics(self, meter: Meter, metrics: dict[str, Instrument]):
146148
)
147149

148150
def _extract_metrics_attributes(self) -> _AttributeMapT:
149-
attributes = {GEN_AI_SYSTEM: GenAiSystemValues.AWS_BEDROCK.value}
151+
attributes = {
152+
GEN_AI_SYSTEM: GenAiSystemValues.AWS_BEDROCK.value,
153+
**get_server_attributes(self._call_context.endpoint_url),
154+
}
150155

151156
model_id = self._call_context.params.get(_MODEL_ID_KEY)
152157
if not model_id:
@@ -163,6 +168,7 @@ def _extract_metrics_attributes(self) -> _AttributeMapT:
163168
attributes[GEN_AI_OPERATION_NAME] = (
164169
GenAiOperationNameValues.CHAT.value
165170
)
171+
166172
return attributes
167173

168174
def extract_attributes(self, attributes: _AttributeMapT):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from __future__ import annotations
15+
16+
from urllib.parse import urlparse
17+
18+
from opentelemetry.semconv._incubating.attributes import (
19+
server_attributes as ServerAttributes,
20+
)
21+
from opentelemetry.util.types import AttributeValue
22+
23+
24+
def get_server_attributes(endpoint_url: str) -> dict[str, AttributeValue]:
25+
"""Extract server.* attributes from AWS endpoint URL."""
26+
parsed = urlparse(endpoint_url)
27+
attributes = {}
28+
if parsed.hostname:
29+
attributes[ServerAttributes.SERVER_ADDRESS] = parsed.hostname
30+
attributes[ServerAttributes.SERVER_PORT] = parsed.port or 443
31+
return attributes

instrumentation/opentelemetry-instrumentation-botocore/tests/bedrock_utils.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
from opentelemetry.semconv._incubating.attributes import (
3333
gen_ai_attributes as GenAIAttributes,
3434
)
35+
from opentelemetry.semconv._incubating.attributes import (
36+
server_attributes as ServerAttributes,
37+
)
3538
from opentelemetry.semconv._incubating.attributes.error_attributes import (
3639
ERROR_TYPE,
3740
)
@@ -221,6 +224,8 @@ def assert_all_attributes(
221224
request_temperature: int | None = None,
222225
request_max_tokens: int | None = None,
223226
request_stop_sequences: tuple[str] | None = None,
227+
server_address: str = "bedrock-runtime.us-east-1.amazonaws.com",
228+
server_port: int = 443,
224229
):
225230
assert span.name == f"{operation_name} {request_model}"
226231
assert (
@@ -235,6 +240,9 @@ def assert_all_attributes(
235240
request_model == span.attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL]
236241
)
237242

243+
assert server_address == span.attributes[ServerAttributes.SERVER_ADDRESS]
244+
assert server_port == span.attributes[ServerAttributes.SERVER_PORT]
245+
238246
assert_equal_or_not_present(
239247
input_tokens, GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS, span
240248
)
@@ -303,7 +311,12 @@ def assert_message_in_logs(log, event_name, expected_content, parent_span):
303311

304312

305313
def assert_all_metric_attributes(
306-
data_point, operation_name: str, model: str, error_type: str | None = None
314+
data_point,
315+
operation_name: str,
316+
model: str,
317+
error_type: str | None = None,
318+
server_address: str = "bedrock-runtime.us-east-1.amazonaws.com",
319+
server_port: int = 443,
307320
):
308321
assert GenAIAttributes.GEN_AI_OPERATION_NAME in data_point.attributes
309322
assert (
@@ -318,6 +331,14 @@ def assert_all_metric_attributes(
318331
assert GenAIAttributes.GEN_AI_REQUEST_MODEL in data_point.attributes
319332
assert data_point.attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL] == model
320333

334+
assert ServerAttributes.SERVER_ADDRESS in data_point.attributes
335+
assert (
336+
data_point.attributes[ServerAttributes.SERVER_ADDRESS]
337+
== server_address
338+
)
339+
assert ServerAttributes.SERVER_PORT in data_point.attributes
340+
assert data_point.attributes[ServerAttributes.SERVER_PORT] == server_port
341+
321342
if error_type is not None:
322343
assert ERROR_TYPE in data_point.attributes
323344
assert data_point.attributes[ERROR_TYPE] == error_type

instrumentation/opentelemetry-instrumentation-botocore/tests/test_botocore_instrumentation.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import json
15+
import os
1516
from unittest.mock import ANY, Mock, patch
1617

1718
import botocore.session
@@ -63,6 +64,9 @@ def _default_span_attributes(self, service: str, operation: str):
6364
"aws.region": self.region,
6465
"retry_attempts": 0,
6566
SpanAttributes.HTTP_STATUS_CODE: 200,
67+
# Some services like IAM or STS have a global endpoint and exclude specified region.
68+
SpanAttributes.SERVER_ADDRESS: f"{service.lower()}.{'' if self.region == 'aws-global' else self.region + '.'}amazonaws.com",
69+
SpanAttributes.SERVER_PORT: 443,
6670
}
6771

6872
def assert_only_span(self):
@@ -330,6 +334,7 @@ def test_sts_client(self):
330334
span = self.assert_only_span()
331335
expected = self._default_span_attributes("STS", "GetCallerIdentity")
332336
expected["aws.request_id"] = ANY
337+
expected[SpanAttributes.SERVER_ADDRESS] = "sts.amazonaws.com"
333338
# check for exact attribute set to make sure not to leak any sts secrets
334339
self.assertEqual(expected, dict(span.attributes))
335340

@@ -497,3 +502,54 @@ def response_hook(span, service_name, operation_name, result):
497502
response_hook_result_attribute_name: 0,
498503
},
499504
)
505+
506+
@mock_aws
507+
def test_server_attributes(self):
508+
# Test regional endpoint
509+
ec2 = self._make_client("ec2")
510+
ec2.describe_instances()
511+
self.assert_span(
512+
"EC2",
513+
"DescribeInstances",
514+
attributes={
515+
SpanAttributes.SERVER_ADDRESS: f"ec2.{self.region}.amazonaws.com",
516+
SpanAttributes.SERVER_PORT: 443,
517+
},
518+
)
519+
self.memory_exporter.clear()
520+
521+
# Test global endpoint
522+
iam_global = self._make_client("iam")
523+
iam_global.list_users()
524+
self.assert_span(
525+
"IAM",
526+
"ListUsers",
527+
attributes={
528+
SpanAttributes.SERVER_ADDRESS: "iam.amazonaws.com",
529+
SpanAttributes.SERVER_PORT: 443,
530+
"aws.region": "aws-global",
531+
},
532+
)
533+
534+
@mock_aws
535+
def test_server_attributes_with_custom_endpoint(self):
536+
with patch.dict(
537+
os.environ,
538+
{"MOTO_S3_CUSTOM_ENDPOINTS": "https://proxy.amazon.org:2025"},
539+
):
540+
s3 = self.session.create_client(
541+
"s3",
542+
region_name=self.region,
543+
endpoint_url="https://proxy.amazon.org:2025",
544+
)
545+
546+
s3.list_buckets()
547+
548+
self.assert_span(
549+
"S3",
550+
"ListBuckets",
551+
attributes={
552+
SpanAttributes.SERVER_ADDRESS: "proxy.amazon.org",
553+
SpanAttributes.SERVER_PORT: 2025,
554+
},
555+
)

0 commit comments

Comments
 (0)