Skip to content

Commit e3dfbfd

Browse files
felix-hildenChromatiustammy-baylis-swixrmx
authored
Support exemplars in WSGI and ASGI histogram (#3739)
* try wsgi fix * ASGI: Add span context to metrics * WSGI: add span context to metrics * Typo and lint fixes * Add changelog * Fix issue with synthetic_test * more lenient delta for basic_metric_success * Add common subTest * Lint fix --------- Co-authored-by: Martin Lundholm <bm.lundholm@gmail.com> Co-authored-by: tammy-baylis-swi <tammy.baylis@solarwinds.com> Co-authored-by: Riccardo Magliocchetti <riccardo.magliocchetti@gmail.com>
1 parent 77373f8 commit e3dfbfd

5 files changed

Lines changed: 194 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Added
1515

16+
- `opentelemetry-instrumentation-asgi`: Add exemplars for `http.server.request.duration` and `http.server.duration` metrics
17+
([#3739](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3739))
18+
- `opentelemetry-instrumentation-wsgi`: Add exemplars for `http.server.request.duration` and `http.server.duration` metrics
19+
([#3739](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3739))
1620
- `opentelemetry-instrumentation-aiohttp-client`: add ability to capture custom headers
1721
([#3988](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3988))
1822
- `opentelemetry-instrumentation-requests`: add ability to capture custom headers
@@ -37,7 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3741

3842
## Version 1.39.0/0.60b0 (2025-12-03)
3943

40-
### Added
44+
### Added
4145

4246
- `opentelemetry-instrumentation-requests`, `opentelemetry-instrumentation-wsgi`, `opentelemetry-instrumentation-asgi` Detect synthetic sources on requests, ASGI, and WSGI.
4347
([#3674](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3674))
@@ -81,10 +85,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8185
([#3882](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3882))
8286
- `opentelemetry-instrumentation-aiohttp-server`: delay initialization of tracer, meter and excluded urls to instrumentation for testability
8387
([#3836](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3836))
84-
- Replace Python 3.14-deprecated `asyncio.iscoroutinefunction` with `inspect.iscoroutinefunction`.
88+
- Replace Python 3.14-deprecated `asyncio.iscoroutinefunction` with `inspect.iscoroutinefunction`.
8589
([#3880](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3880))
8690
- `opentelemetry-instrumentation-elasticsearch`: Enhance elasticsearch query body sanitization
87-
([#3919](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3919))
91+
([#3919](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3919))
8892
- `opentelemetry-instrumentation-pymongo`: Fix span error descriptions
8993
([#3904](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3904))
9094
- build: bump ruff to 0.14.1
@@ -93,7 +97,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9397
([#3941](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3941))
9498
- `opentelemetry-instrumentation-pymongo`: Fix invalid mongodb collection attribute type
9599
([#3942](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3942))
96-
- `opentelemetry-instrumentation-aiohttp-client`: Fix metric attribute leakage
100+
- `opentelemetry-instrumentation-aiohttp-client`: Fix metric attribute leakage
97101
([#3936](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3936))
98102
- `opentelemetry-instrumentation-aiohttp-client`: Update instrumentor to respect suppressing http instrumentation
99103
([#3957](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3957))
@@ -121,7 +125,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
121125
([#3743](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3743))
122126
- Add `rstcheck` to pre-commit to stop introducing invalid RST
123127
([#3777](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3777))
124-
- `opentelemetry-exporter-credential-provider-gcp`: create this package which provides support for supplying your machine's Application Default
128+
- `opentelemetry-exporter-credential-provider-gcp`: create this package which provides support for supplying your machine's Application Default
125129
Credentials (https://cloud.google.com/docs/authentication/application-default-credentials) to the OTLP Exporters created automatically by OpenTelemetry Python's auto instrumentation. These credentials authorize OTLP traces to be sent to `telemetry.googleapis.com`. [#3766](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3766).
126130
- `opentelemetry-instrumentation-psycopg`: Add missing parameter `capture_parameters` to instrumentor.
127131
([#3676](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3676))
@@ -152,7 +156,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
152156
([#3670](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3670))
153157
- `opentelemetry-instrumentation-httpx`: fix missing metric response attributes when tracing is disabled
154158
([#3615](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3615))
155-
- `opentelemetry-instrumentation-fastapi`: Don't pass bounded server_request_hook when using `FastAPIInstrumentor.instrument()`
159+
- `opentelemetry-instrumentation-fastapi`: Don't pass bounded server_request_hook when using `FastAPIInstrumentor.instrument()`
156160
([#3701](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3701))
157161

158162
### Added
@@ -163,7 +167,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
163167
([#3666](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3666))
164168
- `opentelemetry-sdk-extension-aws` Add AWS X-Ray Remote Sampler with initial Rules Poller implementation
165169
([#3366](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3366))
166-
- `opentelemetry-instrumentation`: add support for `OTEL_PYTHON_AUTO_INSTRUMENTATION_EXPERIMENTAL_GEVENT_PATCH` to inform opentelemetry-instrument about gevent monkeypatching
170+
- `opentelemetry-instrumentation`: add support for `OTEL_PYTHON_AUTO_INSTRUMENTATION_EXPERIMENTAL_GEVENT_PATCH` to inform opentelemetry-instrument about gevent monkeypatching
167171
([#3699](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3699))
168172
- `opentelemetry-instrumentation`: botocore: Add support for AWS Step Functions semantic convention attributes
169173
([#3737](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3737))

instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -829,25 +829,34 @@ async def __call__(
829829
duration_attrs_new = _parse_duration_attrs(
830830
attributes, _StabilityMode.HTTP
831831
)
832+
span_ctx = set_span_in_context(span)
832833
if self.duration_histogram_old:
833834
self.duration_histogram_old.record(
834-
max(round(duration_s * 1000), 0), duration_attrs_old
835+
max(round(duration_s * 1000), 0),
836+
duration_attrs_old,
837+
context=span_ctx,
835838
)
836839
if self.duration_histogram_new:
837840
self.duration_histogram_new.record(
838-
max(duration_s, 0), duration_attrs_new
841+
max(duration_s, 0),
842+
duration_attrs_new,
843+
context=span_ctx,
839844
)
840845
self.active_requests_counter.add(
841846
-1, active_requests_count_attrs
842847
)
843848
if self.content_length_header:
844849
if self.server_response_size_histogram:
845850
self.server_response_size_histogram.record(
846-
self.content_length_header, duration_attrs_old
851+
self.content_length_header,
852+
duration_attrs_old,
853+
context=span_ctx,
847854
)
848855
if self.server_response_body_size_histogram:
849856
self.server_response_body_size_histogram.record(
850-
self.content_length_header, duration_attrs_new
857+
self.content_length_header,
858+
duration_attrs_new,
859+
context=span_ctx,
851860
)
852861

853862
request_size = asgi_getter.get(scope, "content-length")
@@ -859,11 +868,15 @@ async def __call__(
859868
else:
860869
if self.server_request_size_histogram:
861870
self.server_request_size_histogram.record(
862-
request_size_amount, duration_attrs_old
871+
request_size_amount,
872+
duration_attrs_old,
873+
context=span_ctx,
863874
)
864875
if self.server_request_body_size_histogram:
865876
self.server_request_body_size_histogram.record(
866-
request_size_amount, duration_attrs_new
877+
request_size_amount,
878+
duration_attrs_new,
879+
context=span_ctx,
867880
)
868881
if token:
869882
context.detach(token)

instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,55 @@ def setUp(self):
315315

316316
self.env_patch.start()
317317

318+
def subTest(self, msg=..., **params):
319+
sub = super().subTest(msg, **params)
320+
# Reinitialize test state to avoid state pollution
321+
self.setUp()
322+
return sub
323+
324+
# Helper to assert exemplars presence across specified histogram metric names.
325+
def _assert_exemplars_present(
326+
self, metric_names: set[str], context: str = ""
327+
):
328+
metrics_list = self.memory_metrics_reader.get_metrics_data()
329+
print(metrics_list)
330+
metrics = []
331+
for resource_metric in (
332+
getattr(metrics_list, "resource_metrics", []) or []
333+
):
334+
for scope_metric in (
335+
getattr(resource_metric, "scope_metrics", []) or []
336+
):
337+
metrics.extend(getattr(scope_metric, "metrics", []) or [])
338+
339+
found = {name: 0 for name in metric_names}
340+
for metric in metrics:
341+
if metric.name not in metric_names:
342+
continue
343+
for point in metric.data.data_points:
344+
found[metric.name] += 1
345+
exemplars = getattr(point, "exemplars", None)
346+
self.assertIsNotNone(
347+
exemplars,
348+
msg=f"Expected exemplars list attribute on histogram data point for {metric.name} ({context})",
349+
)
350+
self.assertGreater(
351+
len(exemplars or []),
352+
0,
353+
msg=f"Expected at least one exemplar on histogram data point for {metric.name} ({context}) but none found.",
354+
)
355+
for ex in exemplars or []:
356+
if hasattr(ex, "span_id"):
357+
self.assertNotEqual(ex.span_id, 0)
358+
if hasattr(ex, "trace_id"):
359+
self.assertNotEqual(ex.trace_id, 0)
360+
for name, count in found.items():
361+
self.assertGreater(
362+
count,
363+
0,
364+
msg=f"Did not encounter any data points for metric {name} while checking exemplars ({context}).",
365+
)
366+
318367
# pylint: disable=too-many-locals
319368
def validate_outputs(
320369
self,
@@ -921,9 +970,6 @@ def update_expected_synthetic_bot(
921970
outputs, modifiers=[update_expected_synthetic_bot]
922971
)
923972

924-
# Clear spans after each test case to prevent accumulation
925-
self.memory_exporter.clear()
926-
927973
async def test_user_agent_synthetic_test_detection(self):
928974
"""Test that test user agents are detected as synthetic with type 'test'"""
929975
test_cases = [
@@ -958,9 +1004,6 @@ def update_expected_synthetic_test(
9581004
outputs, modifiers=[update_expected_synthetic_test]
9591005
)
9601006

961-
# Clear spans after each test case to prevent accumulation
962-
self.memory_exporter.clear()
963-
9641007
async def test_user_agent_non_synthetic(self):
9651008
"""Test that normal user agents are not marked as synthetic"""
9661009
test_cases = [
@@ -996,9 +1039,6 @@ def update_expected_non_synthetic(
9961039
outputs, modifiers=[update_expected_non_synthetic]
9971040
)
9981041

999-
# Clear spans after each test case to prevent accumulation
1000-
self.memory_exporter.clear()
1001-
10021042
async def test_user_agent_synthetic_new_semconv(self):
10031043
"""Test synthetic user agent detection with new semantic conventions"""
10041044
user_agent = b"Mozilla/5.0 (compatible; Googlebot/2.1)"
@@ -1534,6 +1574,40 @@ async def test_asgi_metrics_both_semconv(self):
15341574
)
15351575
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
15361576

1577+
async def test_asgi_metrics_exemplars_expected_old_semconv(self):
1578+
"""Failing test placeholder asserting exemplars should be present for duration histogram (old semconv)."""
1579+
app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
1580+
for _ in range(5):
1581+
self.seed_app(app)
1582+
await self.send_default_request()
1583+
await self.get_all_output()
1584+
self._assert_exemplars_present(
1585+
{"http.server.duration"}, context="old semconv"
1586+
)
1587+
1588+
async def test_asgi_metrics_exemplars_expected_new_semconv(self):
1589+
"""Failing test placeholder asserting exemplars should be present for request duration histogram (new semconv)."""
1590+
app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
1591+
for _ in range(5):
1592+
self.seed_app(app)
1593+
await self.send_default_request()
1594+
await self.get_all_output()
1595+
self._assert_exemplars_present(
1596+
{"http.server.request.duration"}, context="new semconv"
1597+
)
1598+
1599+
async def test_asgi_metrics_exemplars_expected_both_semconv(self):
1600+
"""Failing test placeholder asserting exemplars should be present for both duration histograms when both semconv modes enabled."""
1601+
app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
1602+
for _ in range(5):
1603+
self.seed_app(app)
1604+
await self.send_default_request()
1605+
await self.get_all_output()
1606+
self._assert_exemplars_present(
1607+
{"http.server.duration", "http.server.request.duration"},
1608+
context="both semconv",
1609+
)
1610+
15371611
async def test_basic_metric_success(self):
15381612
app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
15391613
self.seed_app(app)
@@ -1569,7 +1643,7 @@ async def test_basic_metric_success(self):
15691643
self.assertEqual(point.count, 1)
15701644
if metric.name == "http.server.duration":
15711645
self.assertAlmostEqual(
1572-
duration, point.sum, delta=5
1646+
duration, point.sum, delta=30
15731647
)
15741648
elif metric.name == "http.server.response.size":
15751649
self.assertEqual(1024, point.sum)
@@ -1754,7 +1828,7 @@ async def test_basic_metric_success_both_semconv(self):
17541828
)
17551829
elif metric.name == "http.server.duration":
17561830
self.assertAlmostEqual(
1757-
duration, point.sum, delta=5
1831+
duration, point.sum, delta=30
17581832
)
17591833
self.assertDictEqual(
17601834
expected_duration_attributes_old,

instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,7 @@ def _start_response(
667667
return _start_response
668668

669669
# pylint: disable=too-many-branches
670+
# pylint: disable=too-many-locals
670671
def __call__(
671672
self, environ: WSGIEnvironment, start_response: StartResponse
672673
):
@@ -731,19 +732,24 @@ def __call__(
731732
raise
732733
finally:
733734
duration_s = default_timer() - start
735+
active_metric_ctx = trace.set_span_in_context(span)
734736
if self.duration_histogram_old:
735737
duration_attrs_old = _parse_duration_attrs(
736738
req_attrs, _StabilityMode.DEFAULT
737739
)
738740
self.duration_histogram_old.record(
739-
max(round(duration_s * 1000), 0), duration_attrs_old
741+
max(round(duration_s * 1000), 0),
742+
duration_attrs_old,
743+
context=active_metric_ctx,
740744
)
741745
if self.duration_histogram_new:
742746
duration_attrs_new = _parse_duration_attrs(
743747
req_attrs, _StabilityMode.HTTP
744748
)
745749
self.duration_histogram_new.record(
746-
max(duration_s, 0), duration_attrs_new
750+
max(duration_s, 0),
751+
duration_attrs_new,
752+
context=active_metric_ctx,
747753
)
748754
self.active_requests_counter.add(-1, active_requests_count_attrs)
749755

0 commit comments

Comments
 (0)