Skip to content

Commit 93578ff

Browse files
Flask enrich_metric_attributes from Labeler if set
1 parent e76c98c commit 93578ff

File tree

2 files changed

+217
-0
lines changed

2 files changed

+217
-0
lines changed

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,51 @@ def response_hook(span: Span, status: str, response_headers: List):
250250
| ``controller`` | Flask controller/endpoint name. | ``controller='home_view'`` |
251251
+-------------------+----------------------------------------------------+----------------------------------------+
252252
253+
Custom Metrics Attributes using Labeler
254+
***************************************
255+
The Flask instrumentation reads from a labeler utility that supports adding custom
256+
attributes to HTTP server metrics at emit time, including:
257+
258+
- Active requests counter (``http.server.active_requests``)
259+
- Duration histogram (``http.server.duration``)
260+
- Request duration histogram (``http.server.request.duration``)
261+
262+
The custom attributes are stored in the current OpenTelemetry context and are
263+
typically request-scoped for instrumented Flask handlers. In normal application
264+
flow, context detach at request teardown prevents these attributes from leaking
265+
to later requests. Application code typically should not call ``clear_labeler``;
266+
use it primarily for test isolation or manual context-lifecycle management. The
267+
instrumentor does not overwrite base attributes that exist at the same keys as
268+
any custom attributes.
269+
270+
271+
.. code-block:: python
272+
273+
from flask import Flask
274+
275+
from opentelemetry.instrumentation._labeler import get_labeler
276+
from opentelemetry.instrumentation.flask import FlaskInstrumentor
277+
278+
app = Flask(__name__)
279+
FlaskInstrumentor().instrument_app(app)
280+
281+
@app.route("/users/<user_id>/")
282+
def user_profile(user_id):
283+
# Get the labeler for the current request
284+
labeler = get_labeler()
285+
286+
# Add custom attributes to Flask instrumentation metrics
287+
labeler.add("user_id", user_id)
288+
labeler.add("user_type", "registered")
289+
290+
# Or, add multiple attributes at once
291+
labeler.add_attributes({
292+
"feature_flag": "new_ui",
293+
"experiment_group": "control"
294+
})
295+
296+
return f"User profile for {user_id}"
297+
253298
API
254299
---
255300
"""
@@ -266,6 +311,10 @@ def response_hook(span: Span, status: str, response_headers: List):
266311

267312
import opentelemetry.instrumentation.wsgi as otel_wsgi
268313
from opentelemetry import context, trace
314+
from opentelemetry.instrumentation._labeler import (
315+
enrich_metric_attributes,
316+
get_labeler_attributes,
317+
)
269318
from opentelemetry.instrumentation._semconv import (
270319
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
271320
_get_schema_url,
@@ -312,6 +361,7 @@ def response_hook(span: Span, status: str, response_headers: List):
312361
_ENVIRON_ACTIVATION_KEY = "opentelemetry-flask.activation_key"
313362
_ENVIRON_REQCTX_REF_KEY = "opentelemetry-flask.reqctx_ref_key"
314363
_ENVIRON_TOKEN = "opentelemetry-flask.token"
364+
_ENVIRON_LABELER_ATTRIBUTES_KEY = "opentelemetry-flask.labeler_attributes"
315365

316366
_excluded_urls_from_env = get_excluded_urls("FLASK")
317367

@@ -351,6 +401,7 @@ def _rewrapped_app(
351401
duration_histogram_new=None,
352402
):
353403
# pylint: disable=too-many-statements
404+
# pylint: disable=too-many-locals
354405
def _wrapped_app(wrapped_app_environ, start_response):
355406
# We want to measure the time for route matching, etc.
356407
# In theory, we could start the span here and use
@@ -367,6 +418,9 @@ def _wrapped_app(wrapped_app_environ, start_response):
367418
sem_conv_opt_in_mode,
368419
)
369420
)
421+
active_requests_count_attrs = enrich_metric_attributes(
422+
active_requests_count_attrs
423+
)
370424

371425
active_requests_counter.add(1, active_requests_count_attrs)
372426
request_route = None
@@ -437,6 +491,15 @@ def _start_response(status, response_headers, *args, **kwargs):
437491
if request_route:
438492
# http.target to be included in old semantic conventions
439493
duration_attrs_old[HTTP_TARGET] = str(request_route)
494+
duration_attrs_old = enrich_metric_attributes(
495+
duration_attrs_old
496+
)
497+
labeler_metric_attributes = wrapped_app_environ.get(
498+
_ENVIRON_LABELER_ATTRIBUTES_KEY, {}
499+
)
500+
for key, value in labeler_metric_attributes.items():
501+
if key not in duration_attrs_old:
502+
duration_attrs_old[key] = value
440503
duration_histogram_old.record(
441504
max(round(duration_s * 1000), 0),
442505
duration_attrs_old,
@@ -450,12 +513,32 @@ def _start_response(status, response_headers, *args, **kwargs):
450513
if request_route:
451514
duration_attrs_new[HTTP_ROUTE] = str(request_route)
452515

516+
duration_attrs_new = enrich_metric_attributes(
517+
duration_attrs_new
518+
)
519+
labeler_metric_attributes = wrapped_app_environ.get(
520+
_ENVIRON_LABELER_ATTRIBUTES_KEY, {}
521+
)
522+
for key, value in labeler_metric_attributes.items():
523+
if key not in duration_attrs_new:
524+
duration_attrs_new[key] = value
525+
453526
duration_histogram_new.record(
454527
max(duration_s, 0),
455528
duration_attrs_new,
456529
context=metrics_context,
457530
)
458531

532+
active_requests_count_attrs = enrich_metric_attributes(
533+
active_requests_count_attrs
534+
)
535+
labeler_metric_attributes = wrapped_app_environ.get(
536+
_ENVIRON_LABELER_ATTRIBUTES_KEY, {}
537+
)
538+
for key, value in labeler_metric_attributes.items():
539+
if key not in active_requests_count_attrs:
540+
active_requests_count_attrs[key] = value
541+
459542
active_requests_counter.add(-1, active_requests_count_attrs)
460543
return result
461544

@@ -561,6 +644,9 @@ def _teardown_request(exc):
561644

562645
activation = flask.request.environ.get(_ENVIRON_ACTIVATION_KEY)
563646
token = flask.request.environ.get(_ENVIRON_TOKEN)
647+
flask.request.environ[_ENVIRON_LABELER_ATTRIBUTES_KEY] = dict(
648+
get_labeler_attributes()
649+
)
564650

565651
original_reqctx_ref = flask.request.environ.get(
566652
_ENVIRON_REQCTX_REF_KEY

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

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818

1919
from flask import Flask, request
2020

21+
import opentelemetry.instrumentation.flask as otel_flask
2122
from opentelemetry import trace
23+
from opentelemetry.instrumentation._labeler import clear_labeler, get_labeler
2224
from opentelemetry.instrumentation._semconv import (
2325
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
2426
OTEL_SEMCONV_STABILITY_OPT_IN,
@@ -153,6 +155,7 @@ def expected_attributes_new(override_attributes):
153155
class TestProgrammatic(InstrumentationTest, WsgiTestBase):
154156
def setUp(self):
155157
super().setUp()
158+
clear_labeler()
156159

157160
test_name = ""
158161
if hasattr(self, "_testMethodName"):
@@ -180,6 +183,18 @@ def setUp(self):
180183
self.exclude_patch.start()
181184

182185
self.app = Flask(__name__)
186+
187+
@self.app.route("/test_labeler")
188+
def test_labeler_route():
189+
labeler = get_labeler()
190+
labeler.add("custom_attr", "test_value")
191+
labeler.add("http.method", "POST")
192+
return "OK"
193+
194+
@self.app.route("/no_labeler")
195+
def test_no_labeler_route():
196+
return "No labeler"
197+
183198
FlaskInstrumentor().instrument_app(self.app)
184199

185200
self._common_initialization()
@@ -521,6 +536,122 @@ def test_flask_metrics(self):
521536
)
522537
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
523538

539+
def test_flask_metrics_custom_attributes_skip_override(self):
540+
self.client.get("/test_labeler")
541+
metrics = self.get_sorted_metrics(SCOPE)
542+
active_requests_point_seen = False
543+
histogram_point_seen = False
544+
545+
for metric in metrics:
546+
if metric.name == "http.server.active_requests":
547+
data_points = list(metric.data.data_points)
548+
for point in data_points:
549+
self.assertIsInstance(point, NumberDataPoint)
550+
if point.attributes.get("custom_attr") != "test_value":
551+
continue
552+
self.assertEqual(point.attributes[HTTP_METHOD], "GET")
553+
active_requests_point_seen = True
554+
continue
555+
556+
if metric.name != "http.server.duration":
557+
continue
558+
data_points = list(metric.data.data_points)
559+
self.assertEqual(len(data_points), 1)
560+
point = data_points[0]
561+
self.assertIsInstance(point, HistogramDataPoint)
562+
self.assertEqual(point.attributes[HTTP_METHOD], "GET")
563+
self.assertEqual(point.attributes["custom_attr"], "test_value")
564+
histogram_point_seen = True
565+
566+
self.assertTrue(active_requests_point_seen)
567+
self.assertTrue(histogram_point_seen)
568+
569+
def test_flask_metrics_active_requests_custom_attributes_new_semconv(self):
570+
self.client.get("/test_labeler")
571+
metrics = self.get_sorted_metrics(SCOPE)
572+
active_requests_point_seen = False
573+
574+
for metric in metrics:
575+
if metric.name != "http.server.active_requests":
576+
continue
577+
578+
data_points = list(metric.data.data_points)
579+
for point in data_points:
580+
self.assertIsInstance(point, NumberDataPoint)
581+
if point.attributes.get("custom_attr") != "test_value":
582+
continue
583+
self.assertEqual(point.attributes[HTTP_REQUEST_METHOD], "GET")
584+
active_requests_point_seen = True
585+
586+
self.assertTrue(active_requests_point_seen)
587+
588+
def test_flask_active_requests_attrs_use_enrich_old_semconv(self):
589+
with patch(
590+
"opentelemetry.instrumentation.flask.enrich_metric_attributes",
591+
wraps=otel_flask.enrich_metric_attributes,
592+
) as mock_enrich:
593+
self.client.get("/hello/123")
594+
595+
enriched_active_attrs_seen = False
596+
for call in mock_enrich.call_args_list:
597+
if not call.args:
598+
continue
599+
attrs = call.args[0]
600+
if HTTP_METHOD in attrs and HTTP_STATUS_CODE not in attrs:
601+
enriched_active_attrs_seen = True
602+
break
603+
604+
self.assertTrue(enriched_active_attrs_seen)
605+
606+
def test_flask_active_requests_attrs_use_enrich_new_semconv(self):
607+
with patch(
608+
"opentelemetry.instrumentation.flask.enrich_metric_attributes",
609+
wraps=otel_flask.enrich_metric_attributes,
610+
) as mock_enrich:
611+
self.client.get("/hello/123")
612+
613+
enriched_active_attrs_seen = False
614+
for call in mock_enrich.call_args_list:
615+
if not call.args:
616+
continue
617+
attrs = call.args[0]
618+
if (
619+
HTTP_REQUEST_METHOD in attrs
620+
and HTTP_RESPONSE_STATUS_CODE not in attrs
621+
):
622+
enriched_active_attrs_seen = True
623+
break
624+
625+
self.assertTrue(enriched_active_attrs_seen)
626+
627+
def test_flask_metrics_no_labeler(self):
628+
self.client.get("/test_labeler")
629+
self.client.get("/no_labeler")
630+
631+
metrics = self.get_sorted_metrics(SCOPE)
632+
labeler_attrs = None
633+
no_labeler_attrs = None
634+
635+
for metric in metrics:
636+
if metric.name != "http.server.duration":
637+
continue
638+
639+
data_points = list(metric.data.data_points)
640+
self.assertEqual(len(data_points), 2)
641+
642+
for point in data_points:
643+
self.assertIsInstance(point, HistogramDataPoint)
644+
attrs = point.attributes
645+
if attrs.get(HTTP_TARGET) == "/test_labeler":
646+
labeler_attrs = attrs
647+
elif attrs.get(HTTP_TARGET) == "/no_labeler":
648+
no_labeler_attrs = attrs
649+
650+
self.assertIsNotNone(labeler_attrs)
651+
self.assertIsNotNone(no_labeler_attrs)
652+
self.assertEqual(labeler_attrs["custom_attr"], "test_value")
653+
self.assertNotIn("custom_attr", no_labeler_attrs)
654+
524655
def test_flask_metrics_new_semconv(self):
525656
start = default_timer()
526657
self.client.get("/hello/123")

0 commit comments

Comments
 (0)