@@ -291,6 +291,41 @@ def validate_response(
291291 expected_attributes [HTTP_REQUEST_METHOD ] = http_method
292292 self .assertEqual (span_list [0 ].attributes , expected_attributes )
293293
294+ # Helper modeled after ASGI test suite to assert presence of exemplars on histogram metrics
295+ def _assert_exemplars_present (self , metric_names , context = "" ):
296+ metrics_data = self .memory_metrics_reader .get_metrics_data ()
297+ self .assertTrue (
298+ len (metrics_data .resource_metrics ) > 0 ,
299+ f"No resource metrics collected while checking exemplars ({ context } )" ,
300+ )
301+ checked = set ()
302+ for resource_metric in metrics_data .resource_metrics :
303+ for scope_metric in resource_metric .scope_metrics :
304+ for metric in scope_metric .metrics :
305+ if metric .name not in metric_names :
306+ continue
307+ checked .add (metric .name )
308+ # Expect exactly one datapoint per histogram metric in these tests
309+ data_points = list (metric .data .data_points )
310+ self .assertGreater (
311+ len (data_points ),
312+ 0 ,
313+ f"No data points for { metric .name } while checking exemplars ({ context } )" ,
314+ )
315+ for point in data_points :
316+ if isinstance (point , HistogramDataPoint ):
317+ self .assertGreater (
318+ len (point .exemplars ),
319+ 0 ,
320+ f"Expected at least one exemplar on histogram data point for { metric .name } ({ context } ) but none found." ,
321+ )
322+ # Ensure we actually saw all targeted metrics
323+ self .assertSetEqual (
324+ set (metric_names ),
325+ checked ,
326+ f"Did not observe all targeted metrics when asserting exemplars ({ context } ). Expected { metric_names } got { checked } " ,
327+ )
328+
294329 def test_basic_wsgi_call (self ):
295330 app = otel_wsgi .OpenTelemetryMiddleware (simple_wsgi )
296331 response = app (self .environ , self .start_response )
@@ -415,6 +450,42 @@ def test_wsgi_metrics(self):
415450 )
416451 self .assertTrue (number_data_point_seen and histogram_data_point_seen )
417452
453+ def test_wsgi_metrics_exemplars_expected_old_semconv (self ): # type: ignore[func-returns-value]
454+ """Failing test asserting exemplars should be present for duration histogram (old semconv)."""
455+ app = otel_wsgi .OpenTelemetryMiddleware (simple_wsgi )
456+ # generate several requests to increase chance of exemplar sampling
457+ for _ in range (5 ):
458+ response = app (self .environ , self .start_response )
459+ # exhaust response iterable
460+ for _ in response :
461+ pass
462+ self ._assert_exemplars_present (
463+ {"http.server.duration" }, context = "old semconv"
464+ )
465+
466+ def test_wsgi_metrics_exemplars_expected_new_semconv (self ): # type: ignore[func-returns-value]
467+ """Failing test asserting exemplars should be present for request duration histogram (new semconv)."""
468+ app = otel_wsgi .OpenTelemetryMiddleware (simple_wsgi )
469+ for _ in range (5 ):
470+ response = app (self .environ , self .start_response )
471+ for _ in response :
472+ pass
473+ self ._assert_exemplars_present (
474+ {"http.server.request.duration" }, context = "new semconv"
475+ )
476+
477+ def test_wsgi_metrics_exemplars_expected_both_semconv (self ): # type: ignore[func-returns-value]
478+ """Failing test asserting exemplars should be present for both duration histograms when both semconv modes enabled."""
479+ app = otel_wsgi .OpenTelemetryMiddleware (simple_wsgi )
480+ for _ in range (5 ):
481+ response = app (self .environ , self .start_response )
482+ for _ in response :
483+ pass
484+ self ._assert_exemplars_present (
485+ {"http.server.duration" , "http.server.request.duration" },
486+ context = "both semconv" ,
487+ )
488+
418489 def test_wsgi_metrics_new_semconv (self ):
419490 # pylint: disable=too-many-nested-blocks
420491 app = otel_wsgi .OpenTelemetryMiddleware (error_wsgi_unhandled )
0 commit comments