Skip to content

Commit 683930e

Browse files
authored
Fix Starlette auto-instrumentation and botocore propagator configuration (#431)
*Description of changes:* - Enable Starlette auto-instrumentation by removing it from disabled list - Add patch to relax Starlette version constraint (>=0.13, <0.15 to >=0.13) - Fix botocore instrumentation to use global propagator instead of hardcoded XRay <img width="1606" height="669" alt="image" src="https://github.com/user-attachments/assets/48321168-e0de-4e3e-ad0f-10a018e300aa" /> By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent 6b0125c commit 683930e

File tree

7 files changed

+257
-2
lines changed

7 files changed

+257
-2
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def _configure(self, **kwargs):
119119
os.environ.setdefault(
120120
OTEL_PYTHON_DISABLED_INSTRUMENTATIONS,
121121
"http,sqlalchemy,psycopg2,pymysql,sqlite3,aiopg,asyncpg,mysql_connector,"
122-
"urllib3,requests,starlette,system_metrics,google-genai",
122+
"urllib3,requests,system_metrics,google-genai",
123123
)
124124

125125
# Set logging auto instrumentation default

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
)
4646
from opentelemetry.instrumentation.botocore.utils import get_server_attributes
4747
from opentelemetry.instrumentation.utils import is_instrumentation_enabled, suppress_http_instrumentation
48+
from opentelemetry.propagate import get_global_textmap
4849
from opentelemetry.semconv.trace import SpanAttributes
4950
from opentelemetry.trace.span import Span
5051

@@ -54,6 +55,7 @@ def _apply_botocore_instrumentation_patches() -> None:
5455
5556
Adds patches to provide additional support and Java parity for Kinesis, S3, and SQS.
5657
"""
58+
_apply_botocore_propagator_patch()
5759
_apply_botocore_api_call_patch()
5860
_apply_botocore_kinesis_patch()
5961
_apply_botocore_s3_patch()
@@ -66,6 +68,27 @@ def _apply_botocore_instrumentation_patches() -> None:
6668
_apply_botocore_dynamodb_patch()
6769

6870

71+
# Known issue in OpenTelemetry upstream botocore auto-instrumentation
72+
# TODO: Contribute fix upstream and remove from ADOT patch after the contribution
73+
def _apply_botocore_propagator_patch() -> None:
74+
"""Botocore instrumentation patch for propagator
75+
76+
Changes the default propagator from AwsXRayPropagator to the global propagator.
77+
This allows the propagator to be configured via OTEL_PROPAGATORS environment variable.
78+
"""
79+
# Store the original __init__ method
80+
original_init = BotocoreInstrumentor.__init__
81+
82+
def patched_init(self):
83+
# Call the original __init__
84+
original_init(self)
85+
# Replace the propagator with the global one
86+
self.propagator = get_global_textmap()
87+
88+
# Apply the patch
89+
BotocoreInstrumentor.__init__ = patched_init
90+
91+
6992
def _apply_botocore_lambda_patch() -> None:
7093
"""Botocore instrumentation patch for Lambda
7194

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ def apply_instrumentation_patches() -> None:
6161

6262
_apply_botocore_instrumentation_patches()
6363

64+
if is_installed("starlette"):
65+
# pylint: disable=import-outside-toplevel
66+
# Delay import to only occur if patches is safe to apply (e.g. the instrumented library is installed).
67+
from amazon.opentelemetry.distro.patches._starlette_patches import _apply_starlette_instrumentation_patches
68+
69+
# Starlette auto-instrumentation v0.54b includes a strict dependency version check
70+
# This restriction was removed in v1.34.0/0.55b0. Applying temporary patch for Genesis launch
71+
# TODO: Remove patch after syncing with upstream v1.34.0 or later
72+
_apply_starlette_instrumentation_patches()
73+
6474
# No need to check if library is installed as this patches opentelemetry.sdk,
6575
# which must be installed for the distro to work at all.
6676
_apply_resource_detector_patches()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
# Modifications Copyright The OpenTelemetry Authors. Licensed under the Apache License 2.0 License.
4+
from logging import Logger, getLogger
5+
from typing import Collection
6+
7+
_logger: Logger = getLogger(__name__)
8+
9+
10+
# Upstream fix available in OpenTelemetry 1.34.0/0.55b0 (2025-06-04)
11+
# Reference: https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3456
12+
# TODO: Remove this patch after upgrading to version 1.34.0 or later
13+
def _apply_starlette_instrumentation_patches() -> None:
14+
"""Apply patches to the Starlette instrumentation.
15+
16+
This patch modifies the instrumentation_dependencies method in the starlette
17+
instrumentation to loose an upper version constraint for auto-instrumentation
18+
"""
19+
try:
20+
# pylint: disable=import-outside-toplevel
21+
from opentelemetry.instrumentation.starlette import StarletteInstrumentor
22+
23+
# Patch starlette dependencies version check
24+
# Loose the upper check from ("starlette >= 0.13, <0.15",)
25+
def patched_instrumentation_dependencies(self) -> Collection[str]:
26+
return ("starlette >= 0.13",)
27+
28+
# Apply the patch
29+
StarletteInstrumentor.instrumentation_dependencies = patched_instrumentation_dependencies
30+
31+
_logger.debug("Successfully patched Starlette instrumentation_dependencies method")
32+
except Exception as exc: # pylint: disable=broad-except
33+
_logger.warning("Failed to apply Starlette instrumentation patches: %s", exc)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
from unittest import TestCase
4+
from unittest.mock import MagicMock, patch
5+
6+
from amazon.opentelemetry.distro.patches._starlette_patches import _apply_starlette_instrumentation_patches
7+
8+
9+
class TestStarlettePatch(TestCase):
10+
"""Test the Starlette instrumentation patches."""
11+
12+
@patch("amazon.opentelemetry.distro.patches._starlette_patches._logger")
13+
def test_starlette_patch_applied_successfully(self, mock_logger):
14+
"""Test that the Starlette instrumentation patch is applied successfully."""
15+
# Create a mock StarletteInstrumentor class
16+
mock_instrumentor_class = MagicMock()
17+
mock_instrumentor_class.__name__ = "StarletteInstrumentor"
18+
19+
# Create a mock module
20+
mock_starlette_module = MagicMock()
21+
mock_starlette_module.StarletteInstrumentor = mock_instrumentor_class
22+
23+
# Mock the import
24+
with patch.dict("sys.modules", {"opentelemetry.instrumentation.starlette": mock_starlette_module}):
25+
# Apply the patch
26+
_apply_starlette_instrumentation_patches()
27+
28+
# Verify the instrumentation_dependencies method was replaced
29+
self.assertTrue(hasattr(mock_instrumentor_class, "instrumentation_dependencies"))
30+
31+
# Test the patched method returns the expected value
32+
mock_instance = MagicMock()
33+
result = mock_instrumentor_class.instrumentation_dependencies(mock_instance)
34+
self.assertEqual(result, ("starlette >= 0.13",))
35+
36+
# Verify logging
37+
mock_logger.debug.assert_called_once_with(
38+
"Successfully patched Starlette instrumentation_dependencies method"
39+
)
40+
41+
@patch("amazon.opentelemetry.distro.patches._starlette_patches._logger")
42+
def test_starlette_patch_handles_import_error(self, mock_logger):
43+
"""Test that the patch handles import errors gracefully."""
44+
# Mock the import to fail by removing the module
45+
with patch.dict("sys.modules", {"opentelemetry.instrumentation.starlette": None}):
46+
# This should not raise an exception
47+
_apply_starlette_instrumentation_patches()
48+
49+
# Verify warning was logged
50+
mock_logger.warning.assert_called_once()
51+
args = mock_logger.warning.call_args[0]
52+
self.assertIn("Failed to apply Starlette instrumentation patches", args[0])
53+
54+
@patch("amazon.opentelemetry.distro.patches._starlette_patches._logger")
55+
def test_starlette_patch_handles_attribute_error(self, mock_logger):
56+
"""Test that the patch handles attribute errors gracefully."""
57+
58+
# Create a metaclass that raises AttributeError when setting class attributes
59+
class ErrorMeta(type):
60+
def __setattr__(cls, name, value):
61+
if name == "instrumentation_dependencies":
62+
raise AttributeError("Cannot set attribute")
63+
super().__setattr__(name, value)
64+
65+
# Create a class with the error-raising metaclass
66+
class MockStarletteInstrumentor(metaclass=ErrorMeta):
67+
pass
68+
69+
# Create a mock module
70+
mock_starlette_module = MagicMock()
71+
mock_starlette_module.StarletteInstrumentor = MockStarletteInstrumentor
72+
73+
with patch.dict("sys.modules", {"opentelemetry.instrumentation.starlette": mock_starlette_module}):
74+
# This should not raise an exception
75+
_apply_starlette_instrumentation_patches()
76+
77+
# Verify warning was logged
78+
mock_logger.warning.assert_called_once()
79+
args = mock_logger.warning.call_args[0]
80+
self.assertIn("Failed to apply Starlette instrumentation patches", args[0])
81+
82+
def test_starlette_patch_logs_failure_with_no_logger_patch(self): # pylint: disable=no-self-use
83+
"""Test that the patch handles exceptions gracefully without logger mock."""
84+
# Mock the import to fail
85+
with patch.dict("sys.modules", {"opentelemetry.instrumentation.starlette": None}):
86+
# This should not raise an exception even without logger mock
87+
_apply_starlette_instrumentation_patches()
88+
89+
@patch("amazon.opentelemetry.distro.patches._starlette_patches._logger")
90+
def test_starlette_patch_with_exception_during_import(self, mock_logger):
91+
"""Test that the patch handles exceptions during import."""
92+
93+
# Create a module that raises exception when accessing StarletteInstrumentor
94+
class FailingModule:
95+
@property
96+
def StarletteInstrumentor(self): # pylint: disable=invalid-name
97+
raise RuntimeError("Import failed")
98+
99+
failing_module = FailingModule()
100+
101+
with patch.dict("sys.modules", {"opentelemetry.instrumentation.starlette": failing_module}):
102+
# This should not raise an exception
103+
_apply_starlette_instrumentation_patches()
104+
105+
# Verify warning was logged
106+
mock_logger.warning.assert_called_once()
107+
args = mock_logger.warning.call_args[0]
108+
self.assertIn("Failed to apply Starlette instrumentation patches", args[0])

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def test_configure_with_agent_observability_enabled(
127127
self.assertEqual(
128128
os.environ.get("OTEL_PYTHON_DISABLED_INSTRUMENTATIONS"),
129129
"http,sqlalchemy,psycopg2,pymysql,sqlite3,aiopg,asyncpg,mysql_connector,"
130-
"urllib3,requests,starlette,system_metrics,google-genai",
130+
"urllib3,requests,system_metrics,google-genai",
131131
)
132132
self.assertEqual(os.environ.get("OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED"), "true")
133133
self.assertEqual(os.environ.get("OTEL_AWS_APPLICATION_SIGNALS_ENABLED"), "false")

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
)
1717
from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
1818
from opentelemetry.instrumentation.botocore.extensions import _KNOWN_EXTENSIONS
19+
from opentelemetry.propagate import get_global_textmap
1920
from opentelemetry.semconv.trace import SpanAttributes
2021
from opentelemetry.trace.span import Span
2122

@@ -80,14 +81,18 @@ def _run_patch_behaviour_tests(self):
8081

8182
# Validate unpatched upstream behaviour - important to detect upstream changes that may break instrumentation
8283
self._test_unpatched_botocore_instrumentation()
84+
self._test_unpatched_botocore_propagator()
8385
self._test_unpatched_gevent_instrumentation()
86+
self._test_unpatched_starlette_instrumentation()
8487

8588
# Apply patches
8689
apply_instrumentation_patches()
8790

8891
# Validate patched upstream behaviour - important to detect downstream changes that may break instrumentation
8992
self._test_patched_botocore_instrumentation()
93+
self._test_patched_botocore_propagator()
9094
self._test_unpatched_gevent_instrumentation()
95+
self._test_patched_starlette_instrumentation()
9196

9297
# Test setup to check whether only these two modules get patched by gevent monkey
9398
os.environ[AWS_GEVENT_PATCH_MODULES] = "os, ssl"
@@ -123,6 +128,8 @@ def _run_patch_mechanism_tests(self):
123128
self._reset_mocks()
124129
self._test_resource_detector_patches()
125130
self._reset_mocks()
131+
self._test_starlette_installed_flag()
132+
self._reset_mocks()
126133

127134
def _test_unpatched_botocore_instrumentation(self):
128135
# Kinesis
@@ -567,6 +574,80 @@ def _test_resource_detector_patches(self):
567574
# Verify SSL context was created with correct CA file
568575
mock_ssl.assert_called_once_with(cafile="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
569576

577+
def _test_unpatched_botocore_propagator(self):
578+
"""Test that BotocoreInstrumentor uses its own propagator by default."""
579+
# Create a fresh instrumentor to test its initial state
580+
test_instrumentor = BotocoreInstrumentor()
581+
# Check that it has its own propagator (not the global one)
582+
self.assertIsNotNone(test_instrumentor.propagator)
583+
# The default propagator should not be the global propagator initially
584+
# This test ensures upstream hasn't changed their default behavior
585+
586+
def _test_patched_botocore_propagator(self):
587+
"""Test that BotocoreInstrumentor uses global propagator after patching."""
588+
# Create a new instrumentor after patches have been applied
589+
test_instrumentor = BotocoreInstrumentor()
590+
# After patching, the propagator should be the global one
591+
self.assertEqual(test_instrumentor.propagator, get_global_textmap())
592+
593+
def _test_unpatched_starlette_instrumentation(self):
594+
"""Test unpatched starlette instrumentation dependencies."""
595+
try:
596+
# pylint: disable=import-outside-toplevel
597+
from opentelemetry.instrumentation.starlette import StarletteInstrumentor
598+
599+
# Store original method to verify it hasn't been patched yet
600+
original_deps = StarletteInstrumentor.instrumentation_dependencies
601+
# Create an instance to test the method
602+
instrumentor = StarletteInstrumentor()
603+
deps = original_deps(instrumentor)
604+
# Default should have version constraint
605+
self.assertEqual(deps, ("starlette >= 0.13, <0.15",))
606+
except ImportError:
607+
# If starlette instrumentation is not installed, skip this test
608+
pass
609+
610+
def _test_patched_starlette_instrumentation(self):
611+
"""Test patched starlette instrumentation dependencies."""
612+
try:
613+
# pylint: disable=import-outside-toplevel
614+
from opentelemetry.instrumentation.starlette import StarletteInstrumentor
615+
616+
# After patching, the version constraint should be relaxed
617+
instrumentor = StarletteInstrumentor()
618+
deps = instrumentor.instrumentation_dependencies()
619+
self.assertEqual(deps, ("starlette >= 0.13",))
620+
except ImportError:
621+
# If starlette instrumentation is not installed, skip this test
622+
pass
623+
624+
def _test_starlette_installed_flag(self): # pylint: disable=no-self-use
625+
"""Test that starlette patches are only applied when starlette is installed."""
626+
with patch(
627+
"amazon.opentelemetry.distro.patches._starlette_patches._apply_starlette_instrumentation_patches"
628+
) as mock_apply_patches:
629+
# Test when starlette is not installed
630+
with patch(
631+
"amazon.opentelemetry.distro.patches._instrumentation_patch.is_installed", return_value=False
632+
) as mock_is_installed:
633+
apply_instrumentation_patches()
634+
# Check that is_installed was called for starlette
635+
mock_is_installed.assert_any_call("starlette")
636+
# Patches should not be applied when starlette is not installed
637+
mock_apply_patches.assert_not_called()
638+
639+
mock_apply_patches.reset_mock()
640+
641+
# Test when starlette is installed
642+
with patch(
643+
"amazon.opentelemetry.distro.patches._instrumentation_patch.is_installed", return_value=True
644+
) as mock_is_installed:
645+
apply_instrumentation_patches()
646+
# Check that is_installed was called for starlette
647+
mock_is_installed.assert_any_call("starlette")
648+
# Patches should be applied when starlette is installed
649+
mock_apply_patches.assert_called()
650+
570651
def _reset_mocks(self):
571652
for method_patch in self.method_patches.values():
572653
method_patch.reset_mock()

0 commit comments

Comments
 (0)