Skip to content

Commit d905478

Browse files
committed
2 parents 47a8238 + ff89bc5 commit d905478

File tree

11 files changed

+420
-9
lines changed

11 files changed

+420
-9
lines changed

.github/workflows/daily-scan.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ jobs:
9393
id: high_scan
9494
uses: ./.github/actions/image_scan
9595
with:
96-
image-ref: "public.ecr.aws/aws-observability/adot-autoinstrumentation-python:v0.12.1"
96+
image-ref: "public.ecr.aws/aws-observability/adot-autoinstrumentation-python:v0.14.0"
9797
severity: 'CRITICAL,HIGH'
9898
logout: 'false'
9999

@@ -102,7 +102,7 @@ jobs:
102102
id: low_scan
103103
uses: ./.github/actions/image_scan
104104
with:
105-
image-ref: "public.ecr.aws/aws-observability/adot-autoinstrumentation-python:v0.12.1"
105+
image-ref: "public.ecr.aws/aws-observability/adot-autoinstrumentation-python:v0.14.0"
106106
severity: 'MEDIUM,LOW,UNKNOWN'
107107
logout: 'false'
108108

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ For any change that affects end users of this package, please add an entry under
1111
If your change does not need a CHANGELOG entry, add the "skip changelog" label to your PR.
1212

1313
## Unreleased
14+
- Disable django instrumentation if DJANGO_SETTINGS_MODULE is not set
15+
([#549](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/549))
16+
- [PATCH] Add safety check for bedrock ConverseStream responses
17+
([#547](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/547))
18+
- Add Service and Environment dimensions to EMF metrics when Application Signals EMF export is enabled
19+
([#548](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/548))
20+
- Fix CVE-2025-66471. No associated PR since `urllib3` dependency will auto-bump to `2.6.x` upon release.
21+
22+
## v0.14.0 - 2025-11-19
1423
- Add Resource and CFN Attributes for Bedrock AgentCore spans
1524
([#495](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/495))
1625
- Add botocore instrumentation extension for Bedrock AgentCore services with span attributes

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/aws_opentelemetry_distro.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import sys
1717
from logging import ERROR, Logger, getLogger
1818

19-
from amazon.opentelemetry.distro._utils import get_aws_region, is_agent_observability_enabled
19+
from amazon.opentelemetry.distro._utils import get_aws_region, is_agent_observability_enabled, is_installed
2020
from amazon.opentelemetry.distro.aws_opentelemetry_configurator import (
2121
APPLICATION_SIGNALS_ENABLED_CONFIG,
2222
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT,
@@ -86,6 +86,21 @@ def _configure(self, **kwargs):
8686
if cwd_path not in sys.path:
8787
sys.path.insert(0, cwd_path)
8888

89+
# Check if Django is installed and determine if Django instrumentation should be enabled
90+
if is_installed("django"):
91+
# Django instrumentation is allowed when DJANGO_SETTINGS_MODULE is set
92+
if not os.getenv("DJANGO_SETTINGS_MODULE"):
93+
# DJANGO_SETTINGS_MODULE is not set, disable Django instrumentation
94+
disabled_instrumentations = os.getenv("OTEL_PYTHON_DISABLED_INSTRUMENTATIONS", "")
95+
os.environ["OTEL_PYTHON_DISABLED_INSTRUMENTATIONS"] = disabled_instrumentations + ",django"
96+
_logger.warning(
97+
"Django is installed but DJANGO_SETTINGS_MODULE is not set. Disabling django instrumentation."
98+
)
99+
else:
100+
_logger.debug(
101+
"Django instrumentation enabled: DJANGO_SETTINGS_MODULE=%s", os.getenv("DJANGO_SETTINGS_MODULE")
102+
)
103+
89104
os.environ.setdefault(OTEL_EXPORTER_OTLP_PROTOCOL, "http/protobuf")
90105

91106
if os.environ.get(OTEL_PROPAGATORS, None) is None:

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
import json
77
import logging
88
import math
9+
import os
910
import time
1011
from abc import ABC, abstractmethod
1112
from collections import defaultdict
1213
from typing import Any, Dict, List, Optional, Tuple
1314

15+
from amazon.opentelemetry.distro._aws_resource_attribute_configurator import get_service_attribute
1416
from opentelemetry.sdk.metrics import Counter
1517
from opentelemetry.sdk.metrics import Histogram as HistogramInstr
1618
from opentelemetry.sdk.metrics import ObservableCounter, ObservableGauge, ObservableUpDownCounter, UpDownCounter
@@ -28,10 +30,19 @@
2830
)
2931
from opentelemetry.sdk.metrics.view import ExponentialBucketHistogramAggregation
3032
from opentelemetry.sdk.resources import Resource
33+
from opentelemetry.semconv.resource import ResourceAttributes
3134
from opentelemetry.util.types import Attributes
3235

3336
logger = logging.getLogger(__name__)
3437

38+
# Dimension name constants
39+
SERVICE_DIMENSION_NAME: str = "Service"
40+
ENVIRONMENT_DIMENSION_NAME: str = "Environment"
41+
42+
# Constants
43+
LAMBDA_DEFAULT: str = "lambda:default"
44+
UNKNOWN_SERVICE: str = "UnknownService"
45+
3546

3647
class MetricRecord:
3748
"""The metric data unified representation of all OTel metrics for OTel to CW EMF conversion."""
@@ -184,6 +195,53 @@ def _get_dimension_names(self, attributes: Attributes) -> List[str]:
184195
# For now, use all attributes as dimensions
185196
return list(attributes.keys())
186197

198+
def _has_dimension_case_insensitive(self, dimension_names: List[str], dimension_to_check: str) -> bool:
199+
"""Check if dimension already exists (case-insensitive match)."""
200+
dimension_lower = dimension_to_check.lower()
201+
return any(dim.lower() == dimension_lower for dim in dimension_names)
202+
203+
@staticmethod
204+
def _is_application_signals_emf_export_enabled() -> bool:
205+
"""Check if Application Signals EMF export is enabled.
206+
207+
Returns True only if BOTH:
208+
- OTEL_AWS_APPLICATION_SIGNALS_ENABLED is true
209+
- OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED is true
210+
"""
211+
app_signals_enabled = os.environ.get("OTEL_AWS_APPLICATION_SIGNALS_ENABLED", "false").lower() == "true"
212+
emf_export_enabled = (
213+
os.environ.get("OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED", "false").lower() == "true"
214+
)
215+
return app_signals_enabled and emf_export_enabled
216+
217+
def _add_application_signals_dimensions(
218+
self, dimension_names: List[str], emf_log: Dict, resource: Resource
219+
) -> None:
220+
"""Add Service and Environment dimensions if not already present (case-insensitive)."""
221+
if not self._is_application_signals_emf_export_enabled():
222+
return
223+
224+
# Add Service dimension if not already set by user
225+
if not self._has_dimension_case_insensitive(dimension_names, SERVICE_DIMENSION_NAME):
226+
if resource:
227+
service_name, _ = get_service_attribute(resource)
228+
else:
229+
service_name = UNKNOWN_SERVICE
230+
dimension_names.insert(0, SERVICE_DIMENSION_NAME)
231+
emf_log[SERVICE_DIMENSION_NAME] = str(service_name)
232+
233+
# Add Environment dimension if not already set by user
234+
if not self._has_dimension_case_insensitive(dimension_names, ENVIRONMENT_DIMENSION_NAME):
235+
environment_value = None
236+
if resource and resource.attributes:
237+
environment_value = resource.attributes.get(ResourceAttributes.DEPLOYMENT_ENVIRONMENT)
238+
if not environment_value:
239+
environment_value = LAMBDA_DEFAULT
240+
# Insert after Service if it exists, otherwise at the beginning
241+
insert_pos = 1 if SERVICE_DIMENSION_NAME in dimension_names else 0
242+
dimension_names.insert(insert_pos, ENVIRONMENT_DIMENSION_NAME)
243+
emf_log[ENVIRONMENT_DIMENSION_NAME] = str(environment_value)
244+
187245
def _get_attributes_key(self, attributes: Attributes) -> str:
188246
"""
189247
Create a hashable key from attributes for grouping metrics.
@@ -493,6 +551,9 @@ def _create_emf_log(
493551
for name, value in all_attributes.items():
494552
emf_log[name] = str(value)
495553

554+
# Add Service and Environment dimensions if Application Signals EMF export is enabled
555+
self._add_application_signals_dimensions(dimension_names, emf_log, resource)
556+
496557
# Add CloudWatch Metrics if we have metrics, include dimensions only if they exist
497558
if metric_definitions:
498559
cloudwatch_metric = {"Namespace": self.namespace, "Metrics": metric_definitions}

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,9 +334,10 @@ def patched_extract_tool_calls(
334334
tool_calls.append(tool_call)
335335
return tool_calls
336336

337-
# TODO: The following code is to patch a bedrock bug that was fixed in
337+
# TODO: The following code is to patch bedrock bugs that were fixed in
338338
# opentelemetry-instrumentation-botocore==0.60b0 in:
339339
# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3875
340+
# https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3990
340341
# Remove this code once we've bumped opentelemetry-instrumentation-botocore dependency to 0.60b0
341342
def patched_process_anthropic_claude_chunk(self, chunk):
342343
# pylint: disable=too-many-return-statements,too-many-branches
@@ -412,12 +413,30 @@ def patched_process_anthropic_claude_chunk(self, chunk):
412413
self._stream_done_callback(self._response)
413414
return
414415

416+
def patched_from_converse(cls, response: dict[str, Any], capture_content: bool) -> bedrock_utils._Choice:
417+
# be defensive about malformed responses, refer to #3958 for more context
418+
output = response.get("output", {})
419+
orig_message = output.get("message", {})
420+
if role := orig_message.get("role"):
421+
message = {"role": role}
422+
else:
423+
# amazon.titan does not serialize the role
424+
message = {}
425+
426+
if tool_calls := bedrock_utils.extract_tool_calls(orig_message, capture_content):
427+
message["tool_calls"] = tool_calls
428+
elif capture_content and (content := orig_message.get("content")):
429+
message["content"] = content
430+
431+
return cls(message, response["stopReason"], index=0)
432+
415433
bedrock_utils.ConverseStreamWrapper.__init__ = patched_init
416434
bedrock_utils.ConverseStreamWrapper._process_event = patched_process_event
417435
bedrock_utils.InvokeModelWithResponseStreamWrapper._process_anthropic_claude_chunk = (
418436
patched_process_anthropic_claude_chunk
419437
)
420438
bedrock_utils.extract_tool_calls = patched_extract_tool_calls
439+
bedrock_utils._Choice.from_converse = classmethod(patched_from_converse)
421440

422441
# END The OpenTelemetry Authors code
423442

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4-
__version__ = "0.12.1.dev0"
4+
__version__ = "0.14.0.dev0"

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

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4+
import os
45
import unittest
5-
from unittest.mock import Mock
6+
from unittest.mock import Mock, patch
67

78
from amazon.opentelemetry.distro.exporter.aws.metrics.base_emf_exporter import BaseEmfExporter, MetricRecord
89
from opentelemetry.sdk.metrics.export import MetricExportResult
@@ -286,6 +287,173 @@ def test_export_failure_handling(self):
286287
result = self.exporter.export(metrics_data)
287288
self.assertEqual(result, MetricExportResult.FAILURE)
288289

290+
def test_has_dimension_case_insensitive(self):
291+
"""Test case-insensitive dimension checking."""
292+
dimension_names = ["Service", "Environment", "operation"]
293+
294+
# Exact match
295+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "Service"))
296+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "Environment"))
297+
298+
# Case variations
299+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "service"))
300+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "SERVICE"))
301+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "environment"))
302+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "ENVIRONMENT"))
303+
self.assertTrue(self.exporter._has_dimension_case_insensitive(dimension_names, "OPERATION"))
304+
305+
# Non-existent dimension
306+
self.assertFalse(self.exporter._has_dimension_case_insensitive(dimension_names, "NotExists"))
307+
308+
# Empty list
309+
self.assertFalse(self.exporter._has_dimension_case_insensitive([], "Service"))
310+
311+
def test_add_application_signals_dimensions_disabled(self):
312+
"""Test that dimensions are not added when feature is disabled."""
313+
# Default exporter has feature disabled
314+
dimension_names = ["operation"]
315+
emf_log = {}
316+
resource = Resource.create({"service.name": "my-service", "deployment.environment": "production"})
317+
318+
self.exporter._add_application_signals_dimensions(dimension_names, emf_log, resource)
319+
320+
# Dimensions should not be added
321+
self.assertEqual(dimension_names, ["operation"])
322+
self.assertNotIn("Service", emf_log)
323+
self.assertNotIn("Environment", emf_log)
324+
325+
@patch.dict(
326+
os.environ,
327+
{"OTEL_AWS_APPLICATION_SIGNALS_ENABLED": "true", "OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED": "true"},
328+
)
329+
def test_add_application_signals_dimensions_enabled(self):
330+
"""Test that dimensions are added when feature is enabled."""
331+
exporter = ConcreteEmfExporter(namespace="TestNamespace")
332+
dimension_names = ["operation"]
333+
emf_log = {}
334+
resource = Resource.create({"service.name": "my-service", "deployment.environment": "production"})
335+
336+
exporter._add_application_signals_dimensions(dimension_names, emf_log, resource)
337+
338+
# Service and Environment should be added at the beginning
339+
self.assertEqual(dimension_names, ["Service", "Environment", "operation"])
340+
self.assertEqual(emf_log["Service"], "my-service")
341+
self.assertEqual(emf_log["Environment"], "production")
342+
343+
@patch.dict(
344+
os.environ,
345+
{"OTEL_AWS_APPLICATION_SIGNALS_ENABLED": "true", "OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED": "true"},
346+
)
347+
def test_add_application_signals_dimensions_fallback_values(self):
348+
"""Test fallback values when resource attributes are not available."""
349+
exporter = ConcreteEmfExporter(namespace="TestNamespace")
350+
dimension_names = ["operation"]
351+
emf_log = {}
352+
# Resource without deployment.environment
353+
resource = Resource.create({"service.name": "my-service"})
354+
355+
exporter._add_application_signals_dimensions(dimension_names, emf_log, resource)
356+
357+
# Service should use service.name, Environment should fallback to lambda:default
358+
self.assertIn("Service", dimension_names)
359+
self.assertIn("Environment", dimension_names)
360+
self.assertEqual(emf_log["Service"], "my-service")
361+
self.assertEqual(emf_log["Environment"], "lambda:default")
362+
363+
@patch.dict(
364+
os.environ,
365+
{"OTEL_AWS_APPLICATION_SIGNALS_ENABLED": "true", "OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED": "true"},
366+
)
367+
def test_add_application_signals_dimensions_no_resource(self):
368+
"""Test fallback when resource is None."""
369+
exporter = ConcreteEmfExporter(namespace="TestNamespace")
370+
dimension_names = ["operation"]
371+
emf_log = {}
372+
373+
exporter._add_application_signals_dimensions(dimension_names, emf_log, None)
374+
375+
# Should use fallback values
376+
self.assertIn("Service", dimension_names)
377+
self.assertIn("Environment", dimension_names)
378+
self.assertEqual(emf_log["Service"], "UnknownService")
379+
self.assertEqual(emf_log["Environment"], "lambda:default")
380+
381+
@patch.dict(
382+
os.environ,
383+
{"OTEL_AWS_APPLICATION_SIGNALS_ENABLED": "true", "OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED": "true"},
384+
)
385+
def test_add_application_signals_dimensions_service_already_set(self):
386+
"""Test that Service dimension is not overwritten if already set (case-insensitive)."""
387+
exporter = ConcreteEmfExporter(namespace="TestNamespace")
388+
389+
# User has set "service" (lowercase)
390+
dimension_names = ["service", "operation"]
391+
emf_log = {"service": "user-service"}
392+
resource = Resource.create({"service.name": "my-service", "deployment.environment": "production"})
393+
394+
exporter._add_application_signals_dimensions(dimension_names, emf_log, resource)
395+
396+
# Service should NOT be added (case-insensitive match), but Environment should be
397+
self.assertIn("Environment", dimension_names)
398+
self.assertNotIn("Service", dimension_names) # "Service" not added because "service" exists
399+
self.assertEqual(emf_log.get("service"), "user-service") # User value preserved
400+
self.assertEqual(emf_log.get("Environment"), "production")
401+
402+
@patch.dict(
403+
os.environ,
404+
{"OTEL_AWS_APPLICATION_SIGNALS_ENABLED": "true", "OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED": "true"},
405+
)
406+
def test_add_application_signals_dimensions_environment_already_set(self):
407+
"""Test that Environment dimension is not overwritten if already set (case-insensitive)."""
408+
exporter = ConcreteEmfExporter(namespace="TestNamespace")
409+
410+
# User has set "ENVIRONMENT" (uppercase)
411+
dimension_names = ["ENVIRONMENT", "operation"]
412+
emf_log = {"ENVIRONMENT": "user-environment"}
413+
resource = Resource.create({"service.name": "my-service", "deployment.environment": "production"})
414+
415+
exporter._add_application_signals_dimensions(dimension_names, emf_log, resource)
416+
417+
# Environment should NOT be added (case-insensitive match), but Service should be
418+
self.assertIn("Service", dimension_names)
419+
self.assertNotIn("Environment", dimension_names) # "Environment" not added because "ENVIRONMENT" exists
420+
self.assertEqual(emf_log.get("Service"), "my-service")
421+
self.assertEqual(emf_log.get("ENVIRONMENT"), "user-environment") # User value preserved
422+
423+
@patch.dict(
424+
os.environ,
425+
{"OTEL_AWS_APPLICATION_SIGNALS_ENABLED": "true", "OTEL_AWS_APPLICATION_SIGNALS_EMF_EXPORT_ENABLED": "true"},
426+
)
427+
def test_create_emf_log_with_application_signals_enabled(self):
428+
"""Test EMF log creation with Application Signals EMF export enabled."""
429+
exporter = ConcreteEmfExporter(namespace="TestNamespace")
430+
431+
record = exporter._create_metric_record("test_metric", "Count", "Test")
432+
record.value = 50.0
433+
record.timestamp = 1234567890
434+
record.attributes = {"operation": "test"}
435+
436+
records = [record]
437+
resource = Resource.create(
438+
{
439+
"service.name": "test-service",
440+
"deployment.environment": "production",
441+
}
442+
)
443+
444+
result = exporter._create_emf_log(records, resource, 1234567890)
445+
446+
# Check that Service and Environment dimensions were added
447+
self.assertEqual(result["Service"], "test-service")
448+
self.assertEqual(result["Environment"], "production")
449+
450+
# Check CloudWatch metrics dimensions include Service and Environment
451+
cw_metrics = result["_aws"]["CloudWatchMetrics"][0]
452+
dimensions = cw_metrics["Dimensions"][0]
453+
self.assertIn("Service", dimensions)
454+
self.assertIn("Environment", dimensions)
455+
self.assertIn("operation", dimensions)
456+
289457

290458
if __name__ == "__main__":
291459
unittest.main()

0 commit comments

Comments
 (0)