Skip to content

Commit d9a8663

Browse files
liustveaws-application-signals-botgithub-actionsmxiamxia
authored
0.10.1 Patch Release (#438)
*Description of changes:* This PR merges the following commits to `release/0.10.x` 07d9d0d b75ef99 By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. --------- Co-authored-by: aws-application-signals-bot <[email protected]> Co-authored-by: github-actions <[email protected]> Co-authored-by: Min Xia <[email protected]>
1 parent 9734a74 commit d9a8663

File tree

5 files changed

+259
-12
lines changed

5 files changed

+259
-12
lines changed

.github/workflows/daily_scan.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,15 @@ jobs:
8282
id: high_scan
8383
uses: ./.github/actions/image_scan
8484
with:
85-
image-ref: "public.ecr.aws/aws-observability/adot-autoinstrumentation-python:v0.9.0"
85+
image-ref: "public.ecr.aws/aws-observability/adot-autoinstrumentation-python:v0.10.0"
8686
severity: 'CRITICAL,HIGH'
8787

8888
- name: Perform low image scan
8989
if: always()
9090
id: low_scan
9191
uses: ./.github/actions/image_scan
9292
with:
93-
image-ref: "public.ecr.aws/aws-observability/adot-autoinstrumentation-python:v0.9.0"
93+
image-ref: "public.ecr.aws/aws-observability/adot-autoinstrumentation-python:v0.10.0"
9494
severity: 'MEDIUM,LOW,UNKNOWN'
9595

9696
- name: Configure AWS Credentials for emitting metrics

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/aws/metrics/aws_cloudwatch_emf_exporter.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -509,11 +509,13 @@ def _create_emf_log(
509509
for name, value in all_attributes.items():
510510
emf_log[name] = str(value)
511511

512-
# Add the single dimension set to CloudWatch Metrics if we have dimensions and metrics
513-
if dimension_names and metric_definitions:
514-
emf_log["_aws"]["CloudWatchMetrics"].append(
515-
{"Namespace": self.namespace, "Dimensions": [dimension_names], "Metrics": metric_definitions}
516-
)
512+
# Add CloudWatch Metrics if we have metrics, include dimensions only if they exist
513+
if metric_definitions:
514+
cloudwatch_metric = {"Namespace": self.namespace, "Metrics": metric_definitions}
515+
if dimension_names:
516+
cloudwatch_metric["Dimensions"] = [dimension_names]
517+
518+
emf_log["_aws"]["CloudWatchMetrics"].append(cloudwatch_metric)
517519

518520
return emf_log
519521

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/patches/_botocore_patches.py

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# SPDX-License-Identifier: Apache-2.0
33
# Modifications Copyright The OpenTelemetry Authors. Licensed under the Apache License 2.0 License.
44
import importlib
5+
import json
6+
from typing import Any, Dict, Optional, Sequence
57

68
from botocore.exceptions import ClientError
79

@@ -32,7 +34,7 @@
3234
_determine_call_context,
3335
_safe_invoke,
3436
)
35-
from opentelemetry.instrumentation.botocore.extensions import _KNOWN_EXTENSIONS, _find_extension
37+
from opentelemetry.instrumentation.botocore.extensions import _KNOWN_EXTENSIONS, _find_extension, bedrock_utils
3638
from opentelemetry.instrumentation.botocore.extensions.dynamodb import _DynamoDbExtension
3739
from opentelemetry.instrumentation.botocore.extensions.lmbd import _LambdaExtension
3840
from opentelemetry.instrumentation.botocore.extensions.sns import _SnsExtension
@@ -234,7 +236,7 @@ def patch_on_success(self, span: Span, result: _BotoResultT, instrumentor_contex
234236
_SqsExtension.on_success = patch_on_success
235237

236238

237-
def _apply_botocore_bedrock_patch() -> None:
239+
def _apply_botocore_bedrock_patch() -> None: # pylint: disable=too-many-statements
238240
"""Botocore instrumentation patch for Bedrock, Bedrock Agent, and Bedrock Agent Runtime
239241
240242
This patch adds an extension to the upstream's list of known extension for Bedrock.
@@ -245,7 +247,91 @@ def _apply_botocore_bedrock_patch() -> None:
245247
_KNOWN_EXTENSIONS["bedrock"] = _lazy_load(".", "_BedrockExtension")
246248
_KNOWN_EXTENSIONS["bedrock-agent"] = _lazy_load(".", "_BedrockAgentExtension")
247249
_KNOWN_EXTENSIONS["bedrock-agent-runtime"] = _lazy_load(".", "_BedrockAgentRuntimeExtension")
248-
# bedrock-runtime is handled by upstream
250+
251+
# TODO: The following code is to patch bedrock-runtime bugs that are fixed in
252+
# opentelemetry-instrumentation-botocore==0.56b0 in these PRs:
253+
# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3548
254+
# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3544
255+
# Remove this code once we've bumped opentelemetry-instrumentation-botocore dependency to 0.56b0
256+
257+
old_init = bedrock_utils.ConverseStreamWrapper.__init__
258+
old_process_event = bedrock_utils.ConverseStreamWrapper._process_event
259+
260+
# The OpenTelemetry Authors code
261+
def patched_init(self, *args, **kwargs):
262+
old_init(self, *args, **kwargs)
263+
self._tool_json_input_buf = ""
264+
265+
def patched_process_event(self, event):
266+
if "contentBlockStart" in event:
267+
start = event["contentBlockStart"].get("start", {})
268+
if "toolUse" in start:
269+
self._content_block = {"toolUse": start["toolUse"]}
270+
return
271+
272+
if "contentBlockDelta" in event:
273+
if self._record_message:
274+
delta = event["contentBlockDelta"].get("delta", {})
275+
if "text" in delta:
276+
self._content_block.setdefault("text", "")
277+
self._content_block["text"] += delta["text"]
278+
elif "toolUse" in delta:
279+
if (input_buf := delta["toolUse"].get("input")) is not None:
280+
self._tool_json_input_buf += input_buf
281+
return
282+
283+
if "contentBlockStop" in event:
284+
if self._record_message:
285+
if self._tool_json_input_buf:
286+
try:
287+
self._content_block["toolUse"]["input"] = json.loads(self._tool_json_input_buf)
288+
except json.JSONDecodeError:
289+
self._content_block["toolUse"]["input"] = self._tool_json_input_buf
290+
self._message["content"].append(self._content_block)
291+
self._content_block = {}
292+
self._tool_json_input_buf = ""
293+
return
294+
295+
old_process_event(self, event)
296+
297+
def patched_extract_tool_calls(
298+
message: dict[str, Any], capture_content: bool
299+
) -> Optional[Sequence[Dict[str, Any]]]:
300+
content = message.get("content")
301+
if not content:
302+
return None
303+
304+
tool_uses = [item["toolUse"] for item in content if "toolUse" in item]
305+
if not tool_uses:
306+
tool_uses = [item for item in content if isinstance(item, dict) and item.get("type") == "tool_use"]
307+
tool_id_key = "id"
308+
else:
309+
tool_id_key = "toolUseId"
310+
311+
if not tool_uses:
312+
return None
313+
314+
tool_calls = []
315+
for tool_use in tool_uses:
316+
tool_call = {"type": "function"}
317+
if call_id := tool_use.get(tool_id_key):
318+
tool_call["id"] = call_id
319+
320+
if function_name := tool_use.get("name"):
321+
tool_call["function"] = {"name": function_name}
322+
323+
if (function_input := tool_use.get("input")) and capture_content:
324+
tool_call.setdefault("function", {})
325+
tool_call["function"]["arguments"] = function_input
326+
327+
tool_calls.append(tool_call)
328+
return tool_calls
329+
330+
bedrock_utils.ConverseStreamWrapper.__init__ = patched_init
331+
bedrock_utils.ConverseStreamWrapper._process_event = patched_process_event
332+
bedrock_utils.extract_tool_calls = patched_extract_tool_calls
333+
334+
# END The OpenTelemetry Authors code
249335

250336

251337
def _apply_botocore_dynamodb_patch() -> None:
@@ -270,7 +356,7 @@ def patch_on_success(self, span: Span, result: _BotoResultT, instrumentor_contex
270356

271357

272358
def _apply_botocore_api_call_patch() -> None:
273-
# pylint: disable=too-many-locals
359+
# pylint: disable=too-many-locals,too-many-statements
274360
def patched_api_call(self, original_func, instance, args, kwargs):
275361
"""Botocore instrumentation patch to capture AWS authentication details
276362

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/aws/metrics/test_aws_cloudwatch_emf_exporter.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,9 +391,38 @@ def test_create_emf_log_with_resource(self):
391391
# Check CloudWatch metrics structure
392392
cw_metrics = result["_aws"]["CloudWatchMetrics"][0]
393393
self.assertEqual(cw_metrics["Namespace"], "TestNamespace")
394+
self.assertIn("Dimensions", cw_metrics)
394395
self.assertEqual(set(cw_metrics["Dimensions"][0]), {"env", "service"})
395396
self.assertEqual(cw_metrics["Metrics"][0]["Name"], "gauge_metric")
396397

398+
def test_create_emf_log_without_dimensions(self):
399+
"""Test EMF log creation with metrics but no dimensions."""
400+
# Create test record without attributes (no dimensions)
401+
gauge_record = self.exporter._create_metric_record("gauge_metric", "Count", "Gauge")
402+
gauge_record.value = 75.0
403+
gauge_record.timestamp = int(time.time() * 1000)
404+
gauge_record.attributes = {} # No attributes = no dimensions
405+
406+
records = [gauge_record]
407+
resource = Resource.create({"service.name": "test-service"})
408+
409+
result = self.exporter._create_emf_log(records, resource, 1234567890)
410+
411+
# Verify EMF log structure
412+
self.assertIn("_aws", result)
413+
self.assertIn("CloudWatchMetrics", result["_aws"])
414+
self.assertEqual(result["_aws"]["Timestamp"], 1234567890)
415+
self.assertEqual(result["Version"], "1")
416+
417+
# Check metric value
418+
self.assertEqual(result["gauge_metric"], 75.0)
419+
420+
# Check CloudWatch metrics structure - should have metrics but no dimensions
421+
cw_metrics = result["_aws"]["CloudWatchMetrics"][0]
422+
self.assertEqual(cw_metrics["Namespace"], "TestNamespace")
423+
self.assertNotIn("Dimensions", cw_metrics) # No dimensions should be present
424+
self.assertEqual(cw_metrics["Metrics"][0]["Name"], "gauge_metric")
425+
397426
def test_create_emf_log_skips_empty_metric_names(self):
398427
"""Test that EMF log creation skips records with empty metric names."""
399428
# Create a record with no metric name

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/test_instrumentation_patch.py

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
apply_instrumentation_patches,
1616
)
1717
from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
18-
from opentelemetry.instrumentation.botocore.extensions import _KNOWN_EXTENSIONS
18+
from opentelemetry.instrumentation.botocore.extensions import _KNOWN_EXTENSIONS, bedrock_utils
1919
from opentelemetry.propagate import get_global_textmap
2020
from opentelemetry.semconv.trace import SpanAttributes
2121
from opentelemetry.trace.span import Span
@@ -84,6 +84,10 @@ def _run_patch_behaviour_tests(self):
8484
self._test_unpatched_botocore_propagator()
8585
self._test_unpatched_gevent_instrumentation()
8686
self._test_unpatched_starlette_instrumentation()
87+
# TODO: remove these tests once we bump botocore instrumentation version to 0.56b0
88+
# Bedrock Runtime tests
89+
self._test_unpatched_converse_stream_wrapper()
90+
self._test_unpatched_extract_tool_calls()
8791

8892
# Apply patches
8993
apply_instrumentation_patches()
@@ -219,6 +223,11 @@ def _test_patched_botocore_instrumentation(self):
219223
# Bedrock Agent Operation
220224
self._test_patched_bedrock_agent_instrumentation()
221225

226+
# TODO: remove these tests once we bump botocore instrumentation version to 0.56b0
227+
# Bedrock Runtime
228+
self._test_patched_converse_stream_wrapper()
229+
self._test_patched_extract_tool_calls()
230+
222231
# Bedrock Agent Runtime
223232
self.assertTrue("bedrock-agent-runtime" in _KNOWN_EXTENSIONS)
224233
bedrock_agent_runtime_attributes: Dict[str, str] = _do_extract_attributes_bedrock("bedrock-agent-runtime")
@@ -470,6 +479,127 @@ def _test_patched_bedrock_instrumentation(self):
470479
self.assertEqual(len(bedrock_sucess_attributes), 1)
471480
self.assertEqual(bedrock_sucess_attributes["aws.bedrock.guardrail.id"], _BEDROCK_GUARDRAIL_ID)
472481

482+
def _test_unpatched_extract_tool_calls(self):
483+
"""Test unpatched extract_tool_calls with string content throws AttributeError"""
484+
message_with_string_content = {"role": "assistant", "content": "{"}
485+
with self.assertRaises(AttributeError):
486+
bedrock_utils.extract_tool_calls(message_with_string_content, True)
487+
488+
def _test_unpatched_converse_stream_wrapper(self):
489+
"""Test unpatched bedrock-runtime where input values remain as numbers"""
490+
491+
mock_stream = MagicMock()
492+
mock_span = MagicMock()
493+
mock_stream_error_callback = MagicMock()
494+
495+
wrapper = bedrock_utils.ConverseStreamWrapper(mock_stream, mock_span, mock_stream_error_callback)
496+
wrapper._record_message = True
497+
wrapper._message = {"role": "assistant", "content": []}
498+
499+
start_event = {
500+
"contentBlockStart": {
501+
"start": {
502+
"toolUse": {
503+
"toolUseId": "random_id",
504+
"name": "example",
505+
"input": '{"input": 999999999999999999}',
506+
}
507+
},
508+
"contentBlockIndex": 0,
509+
}
510+
}
511+
wrapper._process_event(start_event)
512+
513+
# Validate that _content_block contains toolUse input that has been JSON decoded
514+
self.assertIn("toolUse", wrapper._content_block)
515+
self.assertIn("input", wrapper._content_block["toolUse"])
516+
self.assertIn("input", wrapper._content_block["toolUse"]["input"])
517+
# Validate that input values are numbers (unpatched behavior)
518+
self.assertIsInstance(wrapper._content_block["toolUse"]["input"]["input"], int)
519+
self.assertEqual(wrapper._content_block["toolUse"]["input"]["input"], 999999999999999999)
520+
521+
stop_event = {"contentBlockStop": {"contentBlockIndex": 0}}
522+
wrapper._process_event(stop_event)
523+
524+
expected_tool_use = {
525+
"toolUseId": "random_id",
526+
"name": "example",
527+
"input": {"input": 999999999999999999},
528+
}
529+
self.assertEqual(len(wrapper._message["content"]), 1)
530+
self.assertEqual(wrapper._message["content"][0]["toolUse"], expected_tool_use)
531+
532+
def _test_patched_converse_stream_wrapper(self):
533+
"""Test patched bedrock-runtime"""
534+
535+
# Create mock arguments for ConverseStreamWrapper
536+
mock_stream = MagicMock()
537+
mock_span = MagicMock()
538+
mock_stream_error_callback = MagicMock()
539+
540+
# Create real ConverseStreamWrapper with mocked arguments
541+
wrapper = bedrock_utils.ConverseStreamWrapper(mock_stream, mock_span, mock_stream_error_callback)
542+
wrapper._record_message = True
543+
wrapper._message = {"role": "assistant", "content": []}
544+
545+
# Test contentBlockStart
546+
start_event = {
547+
"contentBlockStart": {
548+
"start": {
549+
"toolUse": {
550+
"toolUseId": "random_id",
551+
"name": "example",
552+
"input": '{"input": 999999999999999999}',
553+
}
554+
},
555+
"contentBlockIndex": 0,
556+
}
557+
}
558+
559+
wrapper._process_event(start_event)
560+
561+
# Validate that _content_block contains toolUse input as literal string (patched behavior)
562+
self.assertIn("toolUse", wrapper._content_block)
563+
self.assertIn("input", wrapper._content_block["toolUse"])
564+
# Validate that input is a string containing the literal JSON (not decoded)
565+
self.assertIsInstance(wrapper._content_block["toolUse"]["input"], str)
566+
self.assertEqual(wrapper._content_block["toolUse"]["input"], '{"input": 999999999999999999}')
567+
568+
# Test contentBlockDelta events
569+
delta_events = [
570+
{"contentBlockDelta": {"delta": {"toolUse": {"input": '{"in'}}, "contentBlockIndex": 0}},
571+
{"contentBlockDelta": {"delta": {"toolUse": {"input": 'put": 9'}}, "contentBlockIndex": 0}},
572+
{"contentBlockDelta": {"delta": {"toolUse": {"input": "99"}}, "contentBlockIndex": 0}},
573+
{"contentBlockDelta": {"delta": {"toolUse": {"input": "99"}}, "contentBlockIndex": 0}},
574+
]
575+
576+
for delta_event in delta_events:
577+
wrapper._process_event(delta_event)
578+
579+
# Verify accumulated input buffer
580+
self.assertEqual(wrapper._tool_json_input_buf, '{"input": 99999')
581+
582+
# Test contentBlockStop
583+
stop_event = {"contentBlockStop": {"contentBlockIndex": 0}}
584+
wrapper._process_event(stop_event)
585+
586+
# Verify final content_block toolUse value (input becomes the accumulated JSON string)
587+
expected_tool_use = {
588+
"toolUseId": "random_id",
589+
"name": "example",
590+
"input": '{"input": 99999',
591+
}
592+
self.assertEqual(len(wrapper._message["content"]), 1)
593+
self.assertEqual(wrapper._message["content"][0]["toolUse"], expected_tool_use)
594+
595+
def _test_patched_extract_tool_calls(self):
596+
"""Test patched extract_tool_calls with string content"""
597+
598+
# Test extract_tool_calls with string content (should return None)
599+
message_with_string_content = {"role": "assistant", "content": "{"}
600+
result = bedrock_utils.extract_tool_calls(message_with_string_content, True)
601+
self.assertIsNone(result)
602+
473603
def _test_patched_bedrock_agent_instrumentation(self):
474604
"""For bedrock-agent service, both extract_attributes and on_success provides attributes,
475605
the attributes depend on the API being invoked."""

0 commit comments

Comments
 (0)