Skip to content

Commit e95c19d

Browse files
Fix FlaskInstrumentor exemplars generation for http.server.(request.)duration (#3912)
* Fix FlaskInstrumentor http.server.request.duration exemplars * Fix FlaskInstrumentor http.server.duration exemplars * Add unit test * Changelog * lint * Add flask functional test for exemplars * lint * More docker-test deps * More concise * Refactor metrics_context assignment, update test --------- Co-authored-by: Riccardo Magliocchetti <[email protected]>
1 parent 38bc548 commit e95c19d

File tree

6 files changed

+178
-3
lines changed

6 files changed

+178
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7474
([#3796](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3796))
7575
- `opentelemetry-instrumentation-fastapi`: Fix handling of APIRoute subclasses
7676
([#3681](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3681))
77+
- `opentelemetry-instrumentation-flask`: Fix exemplars generation for `http.server.request.duration` and `http.server.duration` metrics
78+
([#3912](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3912))
7779

7880
### Added
7981

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ def _rewrapped_app(
342342
sem_conv_opt_in_mode=_StabilityMode.DEFAULT,
343343
duration_histogram_new=None,
344344
):
345+
# pylint: disable=too-many-statements
345346
def _wrapped_app(wrapped_app_environ, start_response):
346347
# We want to measure the time for route matching, etc.
347348
# In theory, we could start the span here and use
@@ -410,6 +411,11 @@ def _start_response(status, response_headers, *args, **kwargs):
410411
result = wsgi_app(wrapped_app_environ, _start_response)
411412
if should_trace:
412413
duration_s = default_timer() - start
414+
# Get the span from wrapped_app_environ and re-create context manually
415+
# to pass to histogram for exemplars generation
416+
span = wrapped_app_environ.get(_ENVIRON_SPAN_KEY)
417+
metrics_context = trace.set_span_in_context(span)
418+
413419
if duration_histogram_old:
414420
duration_attrs_old = otel_wsgi._parse_duration_attrs(
415421
attributes, _StabilityMode.DEFAULT
@@ -418,9 +424,10 @@ def _start_response(status, response_headers, *args, **kwargs):
418424
if request_route:
419425
# http.target to be included in old semantic conventions
420426
duration_attrs_old[HTTP_TARGET] = str(request_route)
421-
422427
duration_histogram_old.record(
423-
max(round(duration_s * 1000), 0), duration_attrs_old
428+
max(round(duration_s * 1000), 0),
429+
duration_attrs_old,
430+
context=metrics_context,
424431
)
425432
if duration_histogram_new:
426433
duration_attrs_new = otel_wsgi._parse_duration_attrs(
@@ -431,7 +438,9 @@ def _start_response(status, response_headers, *args, **kwargs):
431438
duration_attrs_new[HTTP_ROUTE] = str(request_route)
432439

433440
duration_histogram_new.record(
434-
max(duration_s, 0), duration_attrs_new
441+
max(duration_s, 0),
442+
duration_attrs_new,
443+
context=metrics_context,
435444
)
436445
active_requests_counter.add(-1, active_requests_count_attrs)
437446
return result

instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,48 @@ def test_flask_metrics_excluded_urls_new_semconv(self):
810810
self.assertTrue(number_data_point_seen)
811811
self.assertFalse(histogram_data_point_seen)
812812

813+
def test_duration_histogram_old_record_with_context(self):
814+
with patch("opentelemetry.trace.set_span_in_context") as mock_set_span:
815+
self.client.get("/hello/123")
816+
817+
# Verify that trace.set_span_in_context was called for metrics exemplar context
818+
# with same trace, span ID as trace
819+
mock_set_span.assert_called()
820+
call_args = mock_set_span.call_args
821+
self.assertEqual(len(call_args[0]), 1)
822+
span_arg = call_args[0][0]
823+
self.assertIsNotNone(span_arg)
824+
finished_spans = self.memory_exporter.get_finished_spans()
825+
self.assertEqual(len(finished_spans), 1)
826+
finished_span = finished_spans[0]
827+
self.assertEqual(
828+
span_arg.context.trace_id, finished_span.context.trace_id
829+
)
830+
self.assertEqual(
831+
span_arg.context.span_id, finished_span.context.span_id
832+
)
833+
834+
def test_duration_histogram_new_record_with_context_new_semconv(self):
835+
with patch("opentelemetry.trace.set_span_in_context") as mock_set_span:
836+
self.client.get("/hello/123")
837+
838+
# Verify that trace.set_span_in_context was called for metrics exemplar context
839+
# with same trace, span ID as trace
840+
mock_set_span.assert_called()
841+
call_args = mock_set_span.call_args
842+
self.assertEqual(len(call_args[0]), 1)
843+
span_arg = call_args[0][0]
844+
self.assertIsNotNone(span_arg)
845+
finished_spans = self.memory_exporter.get_finished_spans()
846+
self.assertEqual(len(finished_spans), 1)
847+
finished_span = finished_spans[0]
848+
self.assertEqual(
849+
span_arg.context.trace_id, finished_span.context.trace_id
850+
)
851+
self.assertEqual(
852+
span_arg.context.span_id, finished_span.context.span_id
853+
)
854+
813855

814856
class TestProgrammaticHooks(InstrumentationTest, WsgiTestBase):
815857
def setUp(self):
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from flask import Flask
16+
17+
from opentelemetry import metrics as metrics_api
18+
from opentelemetry.instrumentation.flask import FlaskInstrumentor
19+
from opentelemetry.sdk.metrics import AlwaysOnExemplarFilter
20+
from opentelemetry.test.globals_test import (
21+
reset_metrics_globals,
22+
)
23+
from opentelemetry.test.test_base import TestBase
24+
from opentelemetry.trace import (
25+
INVALID_SPAN_ID,
26+
INVALID_TRACE_ID,
27+
)
28+
29+
30+
class TestFunctionalFlask(TestBase):
31+
def setUp(self):
32+
super().setUp()
33+
self.memory_exporter.clear()
34+
# This is done because set_meter_provider cannot override the
35+
# current meter provider.
36+
reset_metrics_globals()
37+
(
38+
self.meter_provider,
39+
self.memory_metrics_reader,
40+
) = self.create_meter_provider(
41+
exemplar_filter=AlwaysOnExemplarFilter(),
42+
)
43+
metrics_api.set_meter_provider(self.meter_provider)
44+
45+
self._app = Flask(__name__)
46+
47+
@self._app.route("/test/")
48+
def test_route():
49+
return "Test response"
50+
51+
self._client = self._app.test_client()
52+
53+
FlaskInstrumentor().instrument_app(
54+
self._app,
55+
meter_provider=self.meter_provider,
56+
)
57+
58+
def tearDown(self):
59+
FlaskInstrumentor().uninstrument()
60+
super().tearDown()
61+
62+
def test_duration_metrics_exemplars(self):
63+
"""Should generate exemplars with trace and span IDs for Flask HTTP requests."""
64+
self._client.get("/test/")
65+
self._client.get("/test/")
66+
self._client.get("/test/")
67+
68+
metrics_data = self.memory_metrics_reader.get_metrics_data()
69+
self.assertIsNotNone(metrics_data)
70+
self.assertTrue(len(metrics_data.resource_metrics) > 0)
71+
72+
duration_metric = None
73+
metric_names = []
74+
for resource_metric in metrics_data.resource_metrics:
75+
for scope_metric in resource_metric.scope_metrics:
76+
for metric in scope_metric.metrics:
77+
metric_names.append(metric.name)
78+
if metric.name in [
79+
"http.server.request.duration",
80+
"http.server.duration",
81+
]:
82+
duration_metric = metric
83+
break
84+
if duration_metric:
85+
break
86+
if duration_metric:
87+
break
88+
89+
self.assertIsNotNone(duration_metric)
90+
data_points = list(duration_metric.data.data_points)
91+
self.assertTrue(len(data_points) > 0)
92+
93+
exemplar_count = 0
94+
for data_point in data_points:
95+
if hasattr(data_point, "exemplars") and data_point.exemplars:
96+
for exemplar in data_point.exemplars:
97+
exemplar_count += 1
98+
# Exemplar has required fields and valid span context
99+
self.assertIsNotNone(exemplar.value)
100+
self.assertIsNotNone(exemplar.time_unix_nano)
101+
self.assertIsNotNone(exemplar.span_id)
102+
self.assertNotEqual(exemplar.span_id, INVALID_SPAN_ID)
103+
self.assertIsNotNone(exemplar.trace_id)
104+
self.assertNotEqual(exemplar.trace_id, INVALID_TRACE_ID)
105+
106+
# Trace and span ID of exemplar are part of finished spans
107+
finished_spans = self.memory_exporter.get_finished_spans()
108+
finished_span_ids = [
109+
span.context.span_id for span in finished_spans
110+
]
111+
finished_trace_ids = [
112+
span.context.trace_id for span in finished_spans
113+
]
114+
self.assertIn(exemplar.span_id, finished_span_ids)
115+
self.assertIn(exemplar.trace_id, finished_trace_ids)
116+
117+
# At least one exemplar was generated
118+
self.assertGreater(exemplar_count, 0)

tests/opentelemetry-docker-tests/tests/test-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dockerpty==0.4.1
2424
docopt==0.6.2
2525
exceptiongroup==1.2.0
2626
flaky==3.7.0
27+
flask==3.0.2
2728
greenlet==3.0.3
2829
grpcio==1.63.2
2930
idna==2.10

tox.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,7 @@ deps =
10081008
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-kafka-python
10091009
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-confluent-kafka
10101010
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-dbapi
1011+
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-flask
10111012
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-mysql
10121013
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-mysqlclient
10131014
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-psycopg
@@ -1019,6 +1020,8 @@ deps =
10191020
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-aiopg
10201021
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-redis
10211022
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-remoulade
1023+
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-wsgi
1024+
-e {toxinidir}/util/opentelemetry-util-http
10221025
opentelemetry-exporter-opencensus@{env:CORE_REPO}\#egg=opentelemetry-exporter-opencensus&subdirectory=exporter/opentelemetry-exporter-opencensus
10231026

10241027
changedir =

0 commit comments

Comments
 (0)