diff --git a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py index 60c43f6db0..40ac1e2983 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py @@ -829,13 +829,18 @@ async def __call__( duration_attrs_new = _parse_duration_attrs( attributes, _StabilityMode.HTTP ) + span_ctx = set_span_in_context(span) if self.duration_histogram_old: self.duration_histogram_old.record( - max(round(duration_s * 1000), 0), duration_attrs_old + max(round(duration_s * 1000), 0), + duration_attrs_old, + context=span_ctx, ) if self.duration_histogram_new: self.duration_histogram_new.record( - max(duration_s, 0), duration_attrs_new + max(duration_s, 0), + duration_attrs_new, + context=span_ctx, ) self.active_requests_counter.add( -1, active_requests_count_attrs @@ -843,11 +848,15 @@ async def __call__( if self.content_length_header: if self.server_response_size_histogram: self.server_response_size_histogram.record( - self.content_length_header, duration_attrs_old + self.content_length_header, + duration_attrs_old, + context=span_ctx, ) if self.server_response_body_size_histogram: self.server_response_body_size_histogram.record( - self.content_length_header, duration_attrs_new + self.content_length_header, + duration_attrs_new, + context=span_ctx, ) request_size = asgi_getter.get(scope, "content-length") @@ -859,11 +868,15 @@ async def __call__( else: if self.server_request_size_histogram: self.server_request_size_histogram.record( - request_size_amount, duration_attrs_old + request_size_amount, + duration_attrs_old, + context=span_ctx, ) if self.server_request_body_size_histogram: self.server_request_body_size_histogram.record( - request_size_amount, duration_attrs_new + request_size_amount, + duration_attrs_new, + context=span_ctx, ) if token: context.detach(token) diff --git a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py index fdf328498b..d94ee9137a 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py @@ -315,6 +315,55 @@ def setUp(self): self.env_patch.start() + def subTest(self, msg=..., **params): + sub = super().subTest(msg, **params) + # Reinitialize test state to avoid state pollution + self.setUp() + return sub + + # Helper to assert exemplars presence across specified histogram metric names. + def _assert_exemplars_present( + self, metric_names: set[str], context: str = "" + ): + metrics_list = self.memory_metrics_reader.get_metrics_data() + print(metrics_list) + metrics = [] + for resource_metric in ( + getattr(metrics_list, "resource_metrics", []) or [] + ): + for scope_metric in ( + getattr(resource_metric, "scope_metrics", []) or [] + ): + metrics.extend(getattr(scope_metric, "metrics", []) or []) + + found = {name: 0 for name in metric_names} + for metric in metrics: + if metric.name not in metric_names: + continue + for point in metric.data.data_points: + found[metric.name] += 1 + exemplars = getattr(point, "exemplars", None) + self.assertIsNotNone( + exemplars, + msg=f"Expected exemplars list attribute on histogram data point for {metric.name} ({context})", + ) + self.assertGreater( + len(exemplars or []), + 0, + msg=f"Expected at least one exemplar on histogram data point for {metric.name} ({context}) but none found.", + ) + for ex in exemplars or []: + if hasattr(ex, "span_id"): + self.assertNotEqual(ex.span_id, 0) + if hasattr(ex, "trace_id"): + self.assertNotEqual(ex.trace_id, 0) + for name, count in found.items(): + self.assertGreater( + count, + 0, + msg=f"Did not encounter any data points for metric {name} while checking exemplars ({context}).", + ) + # pylint: disable=too-many-locals def validate_outputs( self, @@ -921,9 +970,6 @@ def update_expected_synthetic_bot( outputs, modifiers=[update_expected_synthetic_bot] ) - # Clear spans after each test case to prevent accumulation - self.memory_exporter.clear() - async def test_user_agent_synthetic_test_detection(self): """Test that test user agents are detected as synthetic with type 'test'""" test_cases = [ @@ -958,9 +1004,6 @@ def update_expected_synthetic_test( outputs, modifiers=[update_expected_synthetic_test] ) - # Clear spans after each test case to prevent accumulation - self.memory_exporter.clear() - async def test_user_agent_non_synthetic(self): """Test that normal user agents are not marked as synthetic""" test_cases = [ @@ -996,9 +1039,6 @@ def update_expected_non_synthetic( outputs, modifiers=[update_expected_non_synthetic] ) - # Clear spans after each test case to prevent accumulation - self.memory_exporter.clear() - async def test_user_agent_synthetic_new_semconv(self): """Test synthetic user agent detection with new semantic conventions""" user_agent = b"Mozilla/5.0 (compatible; Googlebot/2.1)" @@ -1534,6 +1574,40 @@ async def test_asgi_metrics_both_semconv(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + async def test_asgi_metrics_exemplars_expected_old_semconv(self): + """Failing test placeholder asserting exemplars should be present for duration histogram (old semconv).""" + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + for _ in range(5): + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + self._assert_exemplars_present( + {"http.server.duration"}, context="old semconv" + ) + + async def test_asgi_metrics_exemplars_expected_new_semconv(self): + """Failing test placeholder asserting exemplars should be present for request duration histogram (new semconv).""" + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + for _ in range(5): + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + self._assert_exemplars_present( + {"http.server.request.duration"}, context="new semconv" + ) + + async def test_asgi_metrics_exemplars_expected_both_semconv(self): + """Failing test placeholder asserting exemplars should be present for both duration histograms when both semconv modes enabled.""" + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + for _ in range(5): + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + self._assert_exemplars_present( + {"http.server.duration", "http.server.request.duration"}, + context="both semconv", + ) + async def test_basic_metric_success(self): app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) self.seed_app(app) @@ -1569,7 +1643,7 @@ async def test_basic_metric_success(self): self.assertEqual(point.count, 1) if metric.name == "http.server.duration": self.assertAlmostEqual( - duration, point.sum, delta=5 + duration, point.sum, delta=30 ) elif metric.name == "http.server.response.size": self.assertEqual(1024, point.sum) @@ -1754,7 +1828,7 @@ async def test_basic_metric_success_both_semconv(self): ) elif metric.name == "http.server.duration": self.assertAlmostEqual( - duration, point.sum, delta=5 + duration, point.sum, delta=30 ) self.assertDictEqual( expected_duration_attributes_old,