From 2d288fda93f58e2d14f45b8164366003c335e53c Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 11 Aug 2025 13:33:50 -0700 Subject: [PATCH 01/42] WIP custom attributes metrics labeler --- .../instrumentation/_labeler/__init__.py | 39 ++++ .../_labeler/_internal/__init__.py | 181 ++++++++++++++++++ .../instrumentation/_labeler/example.py | 56 ++++++ .../tests/test_labeler.py | 166 ++++++++++++++++ 4 files changed, 442 insertions(+) create mode 100644 opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py create mode 100644 opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py create mode 100644 opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py create mode 100644 opentelemetry-instrumentation/tests/test_labeler.py diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py new file mode 100644 index 0000000000..50031765a8 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py @@ -0,0 +1,39 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Labeler that supports per-request custom attribute addition to web framework +instrumentor-originating OpenTelemetry metrics. + +This was inspired by OpenTelemetry Go's net/http instrumentation Labeler +https://github.com/open-telemetry/opentelemetry-go-contrib/pull/306 +""" + +from opentelemetry.instrumentation._labeler._internal import ( + Labeler, + get_labeler, + set_labeler, + clear_labeler, + get_labeler_attributes, + enhance_metric_attributes, +) + +__all__ = [ + "Labeler", + "get_labeler", + "set_labeler", + "clear_labeler", + "get_labeler_attributes", + "enhance_metric_attributes", +] diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py new file mode 100644 index 0000000000..a007afabfd --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py @@ -0,0 +1,181 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import threading +from typing import Dict, Union, Optional, Any +import contextvars + +# Context variable to store the current labeler +_labeler_context: contextvars.ContextVar[Optional["Labeler"]] = contextvars.ContextVar( + "otel_labeler", default=None +) + + +class Labeler: + """ + Labeler is used to allow instrumented web applications to add custom attributes + to the metrics recorded by OpenTelemetry instrumentations. + + This class is thread-safe and can be used to accumulate custom attributes + that will be included in OpenTelemetry metrics for the current request. + """ + + def __init__(self): + self._lock = threading.Lock() + self._attributes: Dict[str, Union[str, int, float, bool]] = {} + + def add(self, key: str, value: Union[str, int, float, bool]) -> None: + """ + Add a single attribute to the labeler. + + Args: + key: The attribute key + value: The attribute value (must be a primitive type) + """ + if not isinstance(value, (str, int, float, bool)): + raise ValueError(f"Attribute value must be str, int, float, or bool, got {type(value)}") + + with self._lock: + self._attributes[key] = value + + def add_attributes(self, attributes: Dict[str, Union[str, int, float, bool]]) -> None: + """ + Add multiple attributes to the labeler. + + Args: + attributes: Dictionary of attributes to add + """ + for key, value in attributes.items(): + if not isinstance(value, (str, int, float, bool)): + raise ValueError(f"Attribute value for '{key}' must be str, int, float, or bool, got {type(value)}") + + with self._lock: + self._attributes.update(attributes) + + def get_attributes(self) -> Dict[str, Union[str, int, float, bool]]: + """ + Returns a copy of all attributes added to the labeler. + """ + with self._lock: + return self._attributes.copy() + + def clear(self) -> None: + with self._lock: + self._attributes.clear() + + def __len__(self) -> int: + with self._lock: + return len(self._attributes) + + +def get_labeler() -> Labeler: + """ + Get the Labeler instance for the current request context. + + If no Labeler exists in the current context, a new one is created + and stored in the context. + + Returns: + Labeler instance for the current request, or a new empty Labeler + if not in a request context + """ + labeler = _labeler_context.get() + if labeler is None: + labeler = Labeler() + _labeler_context.set(labeler) + return labeler + + +def set_labeler(labeler: Labeler) -> None: + """ + Set the Labeler instance for the current request context. + + Args: + labeler: The Labeler instance to set + """ + _labeler_context.set(labeler) + + +def clear_labeler() -> None: + """ + Clear the Labeler instance from the current request context. + """ + _labeler_context.set(None) + + +def get_labeler_attributes() -> Dict[str, Union[str, int, float, bool]]: + """ + Get attributes from the current labeler, if any. + + Returns: + Dictionary of custom attributes, or empty dict if no labeler exists + """ + labeler = _labeler_context.get() + if labeler is None: + return {} + return labeler.get_attributes() + + +def enhance_metric_attributes( + base_attributes: Dict[str, Any], + include_custom: bool = True, + max_custom_attrs: int = 20, + max_attr_value_length: int = 100 +) -> Dict[str, Any]: + """ + Enhance metric attributes with custom labeler attributes. + + This function combines base metric attributes with custom attributes + from the current labeler. + + Args: + base_attributes: The base attributes for the metric + include_custom: Whether to include custom labeler attributes + max_custom_attrs: Maximum number of custom attributes to include + max_attr_value_length: Maximum length for string attribute values + + Returns: + Enhanced attributes dictionary combining base and custom attributes + """ + if not include_custom: + return base_attributes.copy() + + # Get custom attributes from labeler + custom_attributes = get_labeler_attributes() + if not custom_attributes: + return base_attributes.copy() + + # Create enhanced attributes dict + enhanced_attributes = base_attributes.copy() + + # Filter and add custom attributes with safety checks + added_count = 0 + for key, value in custom_attributes.items(): + if added_count >= max_custom_attrs: + break + + # Skip attributes that would override base attributes + if key in base_attributes: + continue + + # Apply value length limit for strings + if isinstance(value, str) and len(value) > max_attr_value_length: + value = value[:max_attr_value_length] + + # Only include safe attribute types + if isinstance(value, (str, int, float, bool)): + enhanced_attributes[key] = value + added_count += 1 + + return enhanced_attributes diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py new file mode 100644 index 0000000000..75d0a7b054 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py @@ -0,0 +1,56 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Example Flask application demonstrating how to use Labeler for adding +custom attributes to metrics generated by the Flask instrumentor. +""" + +from flask import Flask +from opentelemetry.instrumentation.flask import FlaskInstrumentor +from opentelemetry.instrumentation._labeler import get_labeler + +app = Flask(__name__) +FlaskInstrumentor().instrument_app(app) + + +@app.route("/healthcheck") +def healthcheck(): + # Get the labeler for the current request + labeler = get_labeler() + + labeler.add_attributes({ + "endpoint_type": "healthcheck", + "internal_request": True, + }) + return "OK" + + +@app.route("/user/") +def user_profile(user_id): + labeler = get_labeler() + + # Can add individual attributes or multiple at once + labeler.add("user_id", user_id) + labeler.add_attributes({ + "has_premium": user_id in ["123", "456"], + "experiment_group": "control", + "feature_enabled": True, + "user_segment": "active" + }) + + return f"Got user profile for {user_id}" + + +if __name__ == "__main__": + app.run(debug=True, port=5000, host='0.0.0.0') diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py new file mode 100644 index 0000000000..e60b4e58f8 --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -0,0 +1,166 @@ +""" +Test cases for the common Labeler functionality in opentelemetry-instrumentation. +""" + +import unittest +import threading +import contextvars + +from opentelemetry.instrumentation._labeler import ( + Labeler, + get_labeler, + set_labeler, + clear_labeler, + get_labeler_attributes, +) + + +class TestLabeler(unittest.TestCase): + def setUp(self): + clear_labeler() + + def test_labeler_init(self): + labeler = Labeler() + self.assertEqual(labeler.get_attributes(), {}) + self.assertEqual(len(labeler), 0) + + def test_add_single_attribute(self): + labeler = Labeler() + labeler.add("test_key", "test_value") + attributes = labeler.get_attributes() + self.assertEqual(attributes, {"test_key": "test_value"}) + self.assertEqual(len(labeler), 1) + + def test_add_multiple_attributes(self): + labeler = Labeler() + labeler.add("key1", "value1") + labeler.add("key2", 42) + labeler.add("key3", True) + labeler.add("key4", 3.14) + attributes = labeler.get_attributes() + expected = {"key1": "value1", "key2": 42, "key3": True, "key4": 3.14} + self.assertEqual(attributes, expected) + self.assertEqual(len(labeler), 4) + + def test_add_attributes_dict(self): + labeler = Labeler() + attrs = {"key1": "value1", "key2": 42, "key3": False} + labeler.add_attributes(attrs) + attributes = labeler.get_attributes() + self.assertEqual(attributes, attrs) + + def test_invalid_attribute_types(self): + labeler = Labeler() + + with self.assertRaises(ValueError): + labeler.add("key", [1, 2, 3]) + + with self.assertRaises(ValueError): + labeler.add("key", {"nested": "dict"}) + + with self.assertRaises(ValueError): + labeler.add_attributes({"key": None}) + + def test_overwrite_attribute(self): + labeler = Labeler() + labeler.add("key1", "original") + labeler.add("key1", "updated") + attributes = labeler.get_attributes() + self.assertEqual(attributes, {"key1": "updated"}) + + def test_clear_attributes(self): + labeler = Labeler() + labeler.add("key1", "value1") + labeler.add("key2", "value2") + labeler.clear() + self.assertEqual(labeler.get_attributes(), {}) + self.assertEqual(len(labeler), 0) + + def test_thread_safety(self): + labeler = Labeler() + num_threads = 10 + num_operations = 100 + + def worker(thread_id): + for i in range(num_operations): + labeler.add(f"thread_{thread_id}_key_{i}", f"value_{i}") + + # Start multiple threads + threads = [] + for thread_id in range(num_threads): + thread = threading.Thread(target=worker, args=(thread_id,)) + threads.append(thread) + thread.start() + + # Wait for all threads to complete + for thread in threads: + thread.join() + + # Check that all attributes were added + attributes = labeler.get_attributes() + expected_count = num_threads * num_operations + self.assertEqual(len(attributes), expected_count) + + +class TestLabelerContext(unittest.TestCase): + def setUp(self): + clear_labeler() + + def test_get_labeler_creates_new(self): + """Test that get_labeler creates a new labeler if none exists.""" + labeler = get_labeler() + self.assertIsInstance(labeler, Labeler) + self.assertEqual(labeler.get_attributes(), {}) + + def test_get_labeler_returns_same_instance(self): + """Test that get_labeler returns the same instance within context.""" + labeler1 = get_labeler() + labeler1.add("test", "value") + labeler2 = get_labeler() + self.assertIs(labeler1, labeler2) + self.assertEqual(labeler2.get_attributes(), {"test": "value"}) + + def test_set_labeler(self): + custom_labeler = Labeler() + custom_labeler.add("custom", "value") + set_labeler(custom_labeler) + retrieved_labeler = get_labeler() + self.assertIs(retrieved_labeler, custom_labeler) + self.assertEqual(retrieved_labeler.get_attributes(), {"custom": "value"}) + + def test_clear_labeler(self): + labeler = get_labeler() + labeler.add("test", "value") + clear_labeler() + # Should get a new labeler after clearing + new_labeler = get_labeler() + self.assertIsNot(new_labeler, labeler) + self.assertEqual(new_labeler.get_attributes(), {}) + + def test_get_labeler_attributes(self): + clear_labeler() + attrs = get_labeler_attributes() + self.assertEqual(attrs, {}) + labeler = get_labeler() + labeler.add("test", "value") + attrs = get_labeler_attributes() + self.assertEqual(attrs, {"test": "value"}) + + def test_context_isolation(self): + def context_worker(context_id, results): + labeler = get_labeler() + labeler.add("context_id", context_id) + labeler.add("value", f"context_{context_id}") + results[context_id] = labeler.get_attributes() + + results = {} + + # Run in different contextvars contexts + for i in range(3): + ctx = contextvars.copy_context() + ctx.run(context_worker, i, results) + + # Each context should have its own labeler with its own values + for i in range(3): + expected = {"context_id": i, "value": f"context_{i}"} + self.assertEqual(results[i], expected) From 930685ea37b7ad723ae668b59d06484ffe70eeb5 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 11 Aug 2025 15:35:01 -0700 Subject: [PATCH 02/42] Add Flask merging of Labeler custom attrs to Flask metrics --- .../instrumentation/flask/__init__.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py index 12db5b9a68..88cdceb9a0 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py @@ -95,6 +95,38 @@ def hello(): if __name__ == "__main__": app.run(debug=True) +Custom Metrics Attributes using Labeler +*************************************** +The Flask instrumentation reads from a Labeler utility that supports adding custom attributes +to the HTTP metrics recorded by the instrumentation. + +.. code-block:: python + + from flask import Flask + from opentelemetry.instrumentation.flask import FlaskInstrumentor + from opentelemetry.instrumentation._labeler import get_labeler + + app = Flask(__name__) + FlaskInstrumentor().instrument_app(app) + + @app.route("/user/") + def user_profile(user_id): + # Get the labeler for the current request + labeler = get_labeler() + + # Add custom attributes to Flask instrumentation metrics + labeler.add("user_id", user_id) + labeler.add("user_type", "registered") + + # Or, add multiple attributes at once + labeler.add_attributes({ + "feature_flag": "new_ui", + "experiment_group": "control" + }) + + return f"User profile for {user_id}" + + Configuration ------------- @@ -268,6 +300,7 @@ def response_hook(span: Span, status: str, response_headers: List): from opentelemetry.instrumentation.flask.package import _instruments from opentelemetry.instrumentation.flask.version import __version__ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation._labeler import enhance_metric_attributes from opentelemetry.instrumentation.propagators import ( get_global_response_propagator, ) @@ -341,6 +374,10 @@ def _wrapped_app(wrapped_app_environ, start_response): attributes = otel_wsgi.collect_request_attributes( wrapped_app_environ, sem_conv_opt_in_mode ) + # Enhance attributes with custom labeler attributes + attributes = enhance_metric_attributes( + attributes, wrapped_app_environ, "request" + ) active_requests_count_attrs = ( otel_wsgi._parse_active_request_count_attrs( attributes, @@ -446,6 +483,10 @@ def _before_request(): flask_request_environ, sem_conv_opt_in_mode=sem_conv_opt_in_mode, ) + # Enhance attributes with custom labeler attributes + attributes = enhance_metric_attributes( + attributes, flask_request_environ, "request" + ) if flask.request.url_rule: # For 404 that result from no route found, etc, we # don't have a url_rule. From a16821a4623bb421475fae2e2dc3f266db3cdda2 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Tue, 12 Aug 2025 17:12:37 -0700 Subject: [PATCH 03/42] Fixes --- .../instrumentation/flask/__init__.py | 16 +++++++++++----- .../instrumentation/_labeler/example.py | 4 ---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py index 88cdceb9a0..8d0009938c 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py @@ -374,10 +374,6 @@ def _wrapped_app(wrapped_app_environ, start_response): attributes = otel_wsgi.collect_request_attributes( wrapped_app_environ, sem_conv_opt_in_mode ) - # Enhance attributes with custom labeler attributes - attributes = enhance_metric_attributes( - attributes, wrapped_app_environ, "request" - ) active_requests_count_attrs = ( otel_wsgi._parse_active_request_count_attrs( attributes, @@ -445,6 +441,11 @@ def _start_response(status, response_headers, *args, **kwargs): request_route ) + # Enhance attributes with any custom labeler attributes + duration_attrs_old = enhance_metric_attributes( + duration_attrs_old + ) + duration_histogram_old.record( max(round(duration_s * 1000), 0), duration_attrs_old ) @@ -456,6 +457,11 @@ def _start_response(status, response_headers, *args, **kwargs): if request_route: duration_attrs_new[HTTP_ROUTE] = str(request_route) + # Enhance attributes with any custom labeler attributes + duration_attrs_new = enhance_metric_attributes( + duration_attrs_new + ) + duration_histogram_new.record( max(duration_s, 0), duration_attrs_new ) @@ -485,7 +491,7 @@ def _before_request(): ) # Enhance attributes with custom labeler attributes attributes = enhance_metric_attributes( - attributes, flask_request_environ, "request" + attributes ) if flask.request.url_rule: # For 404 that result from no route found, etc, we diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py index 75d0a7b054..f9d049c942 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py @@ -50,7 +50,3 @@ def user_profile(user_id): }) return f"Got user profile for {user_id}" - - -if __name__ == "__main__": - app.run(debug=True, port=5000, host='0.0.0.0') From 67e7e8ff2121b498241fb47e3d60086b8233b14d Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Tue, 12 Aug 2025 17:32:48 -0700 Subject: [PATCH 04/42] Docstring --- .../_labeler/_internal/__init__.py | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py index a007afabfd..01be55a991 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py @@ -24,11 +24,13 @@ class Labeler: """ - Labeler is used to allow instrumented web applications to add custom attributes - to the metrics recorded by OpenTelemetry instrumentations. + Labeler can be used by instrumented code or distro to add custom attributes + to the metrics recorded by those OpenTelemetry instrumentations reading it. - This class is thread-safe and can be used to accumulate custom attributes - that will be included in OpenTelemetry metrics for the current request. + Labeler accumulates custom attributes for OpenTelemetry metrics for the + current request in context. + + This feature is experimental and unstable. """ def __init__(self): @@ -134,11 +136,14 @@ def enhance_metric_attributes( max_attr_value_length: int = 100 ) -> Dict[str, Any]: """ - Enhance metric attributes with custom labeler attributes. - - This function combines base metric attributes with custom attributes + This function combines base_attributes with custom attributes from the current labeler. + Custom attributes are skipped if they would override base_attributes, + exceed max_custom_attrs number, or are not simple types (str, int, float, + bool). If custom attributes have string values exceeding the + max_attr_value_length, then they are truncated. + Args: base_attributes: The base attributes for the metric include_custom: Whether to include custom labeler attributes @@ -146,34 +151,27 @@ def enhance_metric_attributes( max_attr_value_length: Maximum length for string attribute values Returns: - Enhanced attributes dictionary combining base and custom attributes + Dictionary combining base and custom attributes """ if not include_custom: return base_attributes.copy() - # Get custom attributes from labeler custom_attributes = get_labeler_attributes() if not custom_attributes: return base_attributes.copy() - # Create enhanced attributes dict enhanced_attributes = base_attributes.copy() - # Filter and add custom attributes with safety checks added_count = 0 for key, value in custom_attributes.items(): if added_count >= max_custom_attrs: break - - # Skip attributes that would override base attributes if key in base_attributes: continue - - # Apply value length limit for strings + if isinstance(value, str) and len(value) > max_attr_value_length: value = value[:max_attr_value_length] - - # Only include safe attribute types + if isinstance(value, (str, int, float, bool)): enhanced_attributes[key] = value added_count += 1 From 4b0c6ed9ff8fcf540285dcc15f34838dcbfe25aa Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Wed, 20 Aug 2025 14:42:20 -0700 Subject: [PATCH 05/42] Ruff --- .../instrumentation/flask/__init__.py | 22 +++----- .../instrumentation/_labeler/__init__.py | 8 +-- .../_labeler/_internal/__init__.py | 56 ++++++++++--------- .../instrumentation/_labeler/example.py | 31 +++++----- .../tests/test_labeler.py | 32 ++++++----- 5 files changed, 78 insertions(+), 71 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py index 8d0009938c..e9f504ced8 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py @@ -97,7 +97,7 @@ def hello(): Custom Metrics Attributes using Labeler *************************************** -The Flask instrumentation reads from a Labeler utility that supports adding custom attributes +The Flask instrumentation reads from a Labeler utility that supports adding custom attributes to the HTTP metrics recorded by the instrumentation. .. code-block:: python @@ -113,17 +113,17 @@ def hello(): def user_profile(user_id): # Get the labeler for the current request labeler = get_labeler() - + # Add custom attributes to Flask instrumentation metrics labeler.add("user_id", user_id) labeler.add("user_type", "registered") - + # Or, add multiple attributes at once labeler.add_attributes({ "feature_flag": "new_ui", "experiment_group": "control" }) - + return f"User profile for {user_id}" @@ -288,6 +288,7 @@ def response_hook(span: Span, status: str, response_headers: List): import opentelemetry.instrumentation.wsgi as otel_wsgi from opentelemetry import context, trace +from opentelemetry.instrumentation._labeler import enhance_metric_attributes from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, _get_schema_url, @@ -300,7 +301,6 @@ def response_hook(span: Span, status: str, response_headers: List): from opentelemetry.instrumentation.flask.package import _instruments from opentelemetry.instrumentation.flask.version import __version__ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor -from opentelemetry.instrumentation._labeler import enhance_metric_attributes from opentelemetry.instrumentation.propagators import ( get_global_response_propagator, ) @@ -442,9 +442,7 @@ def _start_response(status, response_headers, *args, **kwargs): ) # Enhance attributes with any custom labeler attributes - duration_attrs_old = enhance_metric_attributes( - duration_attrs_old - ) + duration_attrs_old = enhance_metric_attributes(duration_attrs_old) duration_histogram_old.record( max(round(duration_s * 1000), 0), duration_attrs_old @@ -458,9 +456,7 @@ def _start_response(status, response_headers, *args, **kwargs): duration_attrs_new[HTTP_ROUTE] = str(request_route) # Enhance attributes with any custom labeler attributes - duration_attrs_new = enhance_metric_attributes( - duration_attrs_new - ) + duration_attrs_new = enhance_metric_attributes(duration_attrs_new) duration_histogram_new.record( max(duration_s, 0), duration_attrs_new @@ -490,9 +486,7 @@ def _before_request(): sem_conv_opt_in_mode=sem_conv_opt_in_mode, ) # Enhance attributes with custom labeler attributes - attributes = enhance_metric_attributes( - attributes - ) + attributes = enhance_metric_attributes(attributes) if flask.request.url_rule: # For 404 that result from no route found, etc, we # don't have a url_rule. diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py index 50031765a8..a1e77d8564 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py @@ -22,17 +22,17 @@ from opentelemetry.instrumentation._labeler._internal import ( Labeler, - get_labeler, - set_labeler, clear_labeler, - get_labeler_attributes, enhance_metric_attributes, + get_labeler, + get_labeler_attributes, + set_labeler, ) __all__ = [ "Labeler", "get_labeler", - "set_labeler", + "set_labeler", "clear_labeler", "get_labeler_attributes", "enhance_metric_attributes", diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py index 01be55a991..7afac12173 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py @@ -12,13 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -import threading -from typing import Dict, Union, Optional, Any import contextvars +import threading +from typing import Any, Dict, Optional, Union # Context variable to store the current labeler -_labeler_context: contextvars.ContextVar[Optional["Labeler"]] = contextvars.ContextVar( - "otel_labeler", default=None +_labeler_context: contextvars.ContextVar[Optional["Labeler"]] = ( + contextvars.ContextVar("otel_labeler", default=None) ) @@ -26,7 +26,7 @@ class Labeler: """ Labeler can be used by instrumented code or distro to add custom attributes to the metrics recorded by those OpenTelemetry instrumentations reading it. - + Labeler accumulates custom attributes for OpenTelemetry metrics for the current request in context. @@ -40,28 +40,34 @@ def __init__(self): def add(self, key: str, value: Union[str, int, float, bool]) -> None: """ Add a single attribute to the labeler. - + Args: key: The attribute key value: The attribute value (must be a primitive type) """ if not isinstance(value, (str, int, float, bool)): - raise ValueError(f"Attribute value must be str, int, float, or bool, got {type(value)}") - + raise ValueError( + f"Attribute value must be str, int, float, or bool, got {type(value)}" + ) + with self._lock: self._attributes[key] = value - def add_attributes(self, attributes: Dict[str, Union[str, int, float, bool]]) -> None: + def add_attributes( + self, attributes: Dict[str, Union[str, int, float, bool]] + ) -> None: """ Add multiple attributes to the labeler. - + Args: attributes: Dictionary of attributes to add """ for key, value in attributes.items(): if not isinstance(value, (str, int, float, bool)): - raise ValueError(f"Attribute value for '{key}' must be str, int, float, or bool, got {type(value)}") - + raise ValueError( + f"Attribute value for '{key}' must be str, int, float, or bool, got {type(value)}" + ) + with self._lock: self._attributes.update(attributes) @@ -84,10 +90,10 @@ def __len__(self) -> int: def get_labeler() -> Labeler: """ Get the Labeler instance for the current request context. - + If no Labeler exists in the current context, a new one is created and stored in the context. - + Returns: Labeler instance for the current request, or a new empty Labeler if not in a request context @@ -102,7 +108,7 @@ def get_labeler() -> Labeler: def set_labeler(labeler: Labeler) -> None: """ Set the Labeler instance for the current request context. - + Args: labeler: The Labeler instance to set """ @@ -119,7 +125,7 @@ def clear_labeler() -> None: def get_labeler_attributes() -> Dict[str, Union[str, int, float, bool]]: """ Get attributes from the current labeler, if any. - + Returns: Dictionary of custom attributes, or empty dict if no labeler exists """ @@ -130,38 +136,38 @@ def get_labeler_attributes() -> Dict[str, Union[str, int, float, bool]]: def enhance_metric_attributes( - base_attributes: Dict[str, Any], + base_attributes: Dict[str, Any], include_custom: bool = True, max_custom_attrs: int = 20, - max_attr_value_length: int = 100 + max_attr_value_length: int = 100, ) -> Dict[str, Any]: """ This function combines base_attributes with custom attributes from the current labeler. - + Custom attributes are skipped if they would override base_attributes, exceed max_custom_attrs number, or are not simple types (str, int, float, bool). If custom attributes have string values exceeding the max_attr_value_length, then they are truncated. - + Args: base_attributes: The base attributes for the metric include_custom: Whether to include custom labeler attributes max_custom_attrs: Maximum number of custom attributes to include max_attr_value_length: Maximum length for string attribute values - + Returns: Dictionary combining base and custom attributes """ if not include_custom: return base_attributes.copy() - + custom_attributes = get_labeler_attributes() if not custom_attributes: return base_attributes.copy() - + enhanced_attributes = base_attributes.copy() - + added_count = 0 for key, value in custom_attributes.items(): if added_count >= max_custom_attrs: @@ -175,5 +181,5 @@ def enhance_metric_attributes( if isinstance(value, (str, int, float, bool)): enhanced_attributes[key] = value added_count += 1 - + return enhanced_attributes diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py index f9d049c942..334d0a25eb 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py @@ -17,8 +17,9 @@ """ from flask import Flask -from opentelemetry.instrumentation.flask import FlaskInstrumentor + from opentelemetry.instrumentation._labeler import get_labeler +from opentelemetry.instrumentation.flask import FlaskInstrumentor app = Flask(__name__) FlaskInstrumentor().instrument_app(app) @@ -29,24 +30,28 @@ def healthcheck(): # Get the labeler for the current request labeler = get_labeler() - labeler.add_attributes({ - "endpoint_type": "healthcheck", - "internal_request": True, - }) + labeler.add_attributes( + { + "endpoint_type": "healthcheck", + "internal_request": True, + } + ) return "OK" @app.route("/user/") def user_profile(user_id): labeler = get_labeler() - + # Can add individual attributes or multiple at once labeler.add("user_id", user_id) - labeler.add_attributes({ - "has_premium": user_id in ["123", "456"], - "experiment_group": "control", - "feature_enabled": True, - "user_segment": "active" - }) - + labeler.add_attributes( + { + "has_premium": user_id in ["123", "456"], + "experiment_group": "control", + "feature_enabled": True, + "user_segment": "active", + } + ) + return f"Got user profile for {user_id}" diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py index e60b4e58f8..ab55094fda 100644 --- a/opentelemetry-instrumentation/tests/test_labeler.py +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -2,16 +2,16 @@ Test cases for the common Labeler functionality in opentelemetry-instrumentation. """ -import unittest -import threading import contextvars +import threading +import unittest from opentelemetry.instrumentation._labeler import ( Labeler, - get_labeler, - set_labeler, clear_labeler, + get_labeler, get_labeler_attributes, + set_labeler, ) @@ -51,13 +51,13 @@ def test_add_attributes_dict(self): def test_invalid_attribute_types(self): labeler = Labeler() - + with self.assertRaises(ValueError): labeler.add("key", [1, 2, 3]) - + with self.assertRaises(ValueError): labeler.add("key", {"nested": "dict"}) - + with self.assertRaises(ValueError): labeler.add_attributes({"key": None}) @@ -80,22 +80,22 @@ def test_thread_safety(self): labeler = Labeler() num_threads = 10 num_operations = 100 - + def worker(thread_id): for i in range(num_operations): labeler.add(f"thread_{thread_id}_key_{i}", f"value_{i}") - + # Start multiple threads threads = [] for thread_id in range(num_threads): thread = threading.Thread(target=worker, args=(thread_id,)) threads.append(thread) thread.start() - + # Wait for all threads to complete for thread in threads: thread.join() - + # Check that all attributes were added attributes = labeler.get_attributes() expected_count = num_threads * num_operations @@ -126,7 +126,9 @@ def test_set_labeler(self): set_labeler(custom_labeler) retrieved_labeler = get_labeler() self.assertIs(retrieved_labeler, custom_labeler) - self.assertEqual(retrieved_labeler.get_attributes(), {"custom": "value"}) + self.assertEqual( + retrieved_labeler.get_attributes(), {"custom": "value"} + ) def test_clear_labeler(self): labeler = get_labeler() @@ -152,14 +154,14 @@ def context_worker(context_id, results): labeler.add("context_id", context_id) labeler.add("value", f"context_{context_id}") results[context_id] = labeler.get_attributes() - + results = {} - + # Run in different contextvars contexts for i in range(3): ctx = contextvars.copy_context() ctx.run(context_worker, i, results) - + # Each context should have its own labeler with its own values for i in range(3): expected = {"context_id": i, "value": f"context_{i}"} From ce12b1b1708c3b00574536c7f33989368d60e393 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Wed, 20 Aug 2025 15:23:58 -0700 Subject: [PATCH 06/42] Mv example to docstring; lint --- .../instrumentation/_labeler/__init__.py | 57 ++++++++++++++++++- .../_labeler/_internal/__init__.py | 18 ++---- .../instrumentation/_labeler/example.py | 57 ------------------- .../tests/test_labeler.py | 20 ++++--- 4 files changed, 74 insertions(+), 78 deletions(-) delete mode 100644 opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py index a1e77d8564..c1679a8ba3 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py @@ -13,11 +13,64 @@ # limitations under the License. """ -Labeler that supports per-request custom attribute addition to web framework -instrumentor-originating OpenTelemetry metrics. +OpenTelemetry Labeler +===================== + +The labeler utility provides a way to add custom attributes to metrics generated by OpenTelemetry instrumentors. This supports enriching metrics with context-specific information available only after HTTP requests are received, at their origin rather than requiring downstream processing or additional metric calculations. This was inspired by OpenTelemetry Go's net/http instrumentation Labeler https://github.com/open-telemetry/opentelemetry-go-contrib/pull/306 + +Usage +----- + +The labeler works within the context of an instrumented request or operation. Use ``get_labeler()`` to obtain a labeler instance for the current context, then add attributes using the ``add()`` or ``add_attributes()`` methods. + +Example with Flask +------------------ + +Here's an example showing how to use the labeler with Flask instrumentation: + +.. code-block:: python + + from flask import Flask + from opentelemetry.instrumentation._labeler import get_labeler + from opentelemetry.instrumentation.flask import FlaskInstrumentor + + app = Flask(__name__) + FlaskInstrumentor().instrument_app(app) + + @app.route("/healthcheck") + def healthcheck(): + # Get the labeler for the current request + labeler = get_labeler() + + labeler.add_attributes( + { + "endpoint_type": "healthcheck", + "internal_request": True, + } + ) + return "OK" + + @app.route("/user/") + def user_profile(user_id): + labeler = get_labeler() + + # Can add individual attributes or multiple at once + labeler.add("user_id", user_id) + labeler.add_attributes( + { + "has_premium": user_id in ["123", "456"], + "experiment_group": "control", + "feature_enabled": True, + "user_segment": "active", + } + ) + + return f"Got user profile for {user_id}" + +The labeler also works with auto-instrumentation and those instrumentors that have implemented custom attributes support for metrics. """ from opentelemetry.instrumentation._labeler._internal import ( diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py index 7afac12173..05ef79f934 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py @@ -24,11 +24,7 @@ class Labeler: """ - Labeler can be used by instrumented code or distro to add custom attributes - to the metrics recorded by those OpenTelemetry instrumentations reading it. - - Labeler accumulates custom attributes for OpenTelemetry metrics for the - current request in context. + Stores custom attributes for the current request in context. This feature is experimental and unstable. """ @@ -142,13 +138,11 @@ def enhance_metric_attributes( max_attr_value_length: int = 100, ) -> Dict[str, Any]: """ - This function combines base_attributes with custom attributes - from the current labeler. - - Custom attributes are skipped if they would override base_attributes, - exceed max_custom_attrs number, or are not simple types (str, int, float, - bool). If custom attributes have string values exceeding the - max_attr_value_length, then they are truncated. + Combines base_attributes with custom attributes from the current labeler, + returning a new dictionary of attributes. Custom attributes are skipped + if they would override base_attributes, exceed max_custom_attrs number, + or are not simple types (str, int, float, bool). If custom attributes + have string values exceeding the max_attr_value_length, then they are truncated. Args: base_attributes: The base attributes for the metric diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py deleted file mode 100644 index 334d0a25eb..0000000000 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/example.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright The OpenTelemetry Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -Example Flask application demonstrating how to use Labeler for adding -custom attributes to metrics generated by the Flask instrumentor. -""" - -from flask import Flask - -from opentelemetry.instrumentation._labeler import get_labeler -from opentelemetry.instrumentation.flask import FlaskInstrumentor - -app = Flask(__name__) -FlaskInstrumentor().instrument_app(app) - - -@app.route("/healthcheck") -def healthcheck(): - # Get the labeler for the current request - labeler = get_labeler() - - labeler.add_attributes( - { - "endpoint_type": "healthcheck", - "internal_request": True, - } - ) - return "OK" - - -@app.route("/user/") -def user_profile(user_id): - labeler = get_labeler() - - # Can add individual attributes or multiple at once - labeler.add("user_id", user_id) - labeler.add_attributes( - { - "has_premium": user_id in ["123", "456"], - "experiment_group": "control", - "feature_enabled": True, - "user_segment": "active", - } - ) - - return f"Got user profile for {user_id}" diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py index ab55094fda..b10d54ccc2 100644 --- a/opentelemetry-instrumentation/tests/test_labeler.py +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -82,8 +82,11 @@ def test_thread_safety(self): num_operations = 100 def worker(thread_id): - for i in range(num_operations): - labeler.add(f"thread_{thread_id}_key_{i}", f"value_{i}") + for i_operation in range(num_operations): + labeler.add( + f"thread_{thread_id}_key_{i_operation}", + f"value_{i_operation}", + ) # Start multiple threads threads = [] @@ -158,11 +161,14 @@ def context_worker(context_id, results): results = {} # Run in different contextvars contexts - for i in range(3): + for i_operation in range(3): ctx = contextvars.copy_context() - ctx.run(context_worker, i, results) + ctx.run(context_worker, i_operation, results) # Each context should have its own labeler with its own values - for i in range(3): - expected = {"context_id": i, "value": f"context_{i}"} - self.assertEqual(results[i], expected) + for i_operation in range(3): + expected = { + "context_id": i_operation, + "value": f"context_{i_operation}", + } + self.assertEqual(results[i_operation], expected) From 96c0aad920b864e6bfb0c621fe4f89653cfabe5a Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Wed, 20 Aug 2025 16:12:46 -0700 Subject: [PATCH 07/42] Header --- .../tests/test_labeler.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py index b10d54ccc2..98ca985d62 100644 --- a/opentelemetry-instrumentation/tests/test_labeler.py +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -1,6 +1,17 @@ -""" -Test cases for the common Labeler functionality in opentelemetry-instrumentation. -""" +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# type: ignore import contextvars import threading From 4eeecf6d58febe64453a8863c960b2e69dfc7681 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Wed, 20 Aug 2025 16:29:39 -0700 Subject: [PATCH 08/42] Add contextvar tests --- .../tests/test_labeler.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py index 98ca985d62..11b88a158f 100644 --- a/opentelemetry-instrumentation/tests/test_labeler.py +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -24,6 +24,7 @@ get_labeler_attributes, set_labeler, ) +from opentelemetry.instrumentation._labeler._internal import _labeler_context class TestLabeler(unittest.TestCase): @@ -183,3 +184,39 @@ def context_worker(context_id, results): "value": f"context_{i_operation}", } self.assertEqual(results[i_operation], expected) + + +class TestLabelerContextVar(unittest.TestCase): + def setUp(self): + clear_labeler() + + def test_contextvar_name_and_api_consistency(self): + self.assertEqual(_labeler_context.name, "otel_labeler") + labeler = get_labeler() + labeler.add("test", "value") + ctx_labeler = _labeler_context.get() + self.assertIs(labeler, ctx_labeler) + + def test_contextvar_isolation(self): + def context_worker(worker_id, results): + labeler = get_labeler() + labeler.add("worker_id", worker_id) + results[worker_id] = labeler.get_attributes() + + results = {} + for worker_id in range(3): + ctx = contextvars.copy_context() + ctx.run(context_worker, worker_id, results) + for worker_id in range(3): + expected = {"worker_id": worker_id} + self.assertEqual(results[worker_id], expected) + + def test_clear_and_get_labeler_contextvar(self): + labeler = get_labeler() + labeler.add("test", "value") + self.assertIs(_labeler_context.get(), labeler) + clear_labeler() + self.assertIsNone(_labeler_context.get()) + new_labeler = get_labeler() + self.assertIsNot(new_labeler, labeler) + self.assertEqual(new_labeler.get_attributes(), {}) From 0199858650849931537bffef87421feef398eacb Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Thu, 21 Aug 2025 14:51:43 -0700 Subject: [PATCH 09/42] Fix: custom attrs Flask to request_counter, not span --- .../src/opentelemetry/instrumentation/flask/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py index e9f504ced8..275e2d0c6a 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py @@ -380,6 +380,10 @@ def _wrapped_app(wrapped_app_environ, start_response): sem_conv_opt_in_mode, ) ) + # Enhance attributes with any custom labeler attributes + active_requests_count_attrs = enhance_metric_attributes( + active_requests_count_attrs + ) active_requests_counter.add(1, active_requests_count_attrs) request_route = None @@ -485,8 +489,6 @@ def _before_request(): flask_request_environ, sem_conv_opt_in_mode=sem_conv_opt_in_mode, ) - # Enhance attributes with custom labeler attributes - attributes = enhance_metric_attributes(attributes) if flask.request.url_rule: # For 404 that result from no route found, etc, we # don't have a url_rule. From 570e9948e0ce39c820aee7f135638586d85bb861 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Thu, 21 Aug 2025 17:14:49 -0700 Subject: [PATCH 10/42] Rm custom attrs from updown active requests count --- .../src/opentelemetry/instrumentation/flask/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py index 275e2d0c6a..ba268bfc43 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py @@ -380,11 +380,6 @@ def _wrapped_app(wrapped_app_environ, start_response): sem_conv_opt_in_mode, ) ) - # Enhance attributes with any custom labeler attributes - active_requests_count_attrs = enhance_metric_attributes( - active_requests_count_attrs - ) - active_requests_counter.add(1, active_requests_count_attrs) request_route = None From da7d8f8cd6cb22fa1b7a5779300a7e7f0134434d Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 22 Aug 2025 14:55:03 -0700 Subject: [PATCH 11/42] Add FlaskInstrumentor custom attrs tests --- .../tests/test_labeler.py | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 instrumentation/opentelemetry-instrumentation-flask/tests/test_labeler.py diff --git a/instrumentation/opentelemetry-instrumentation-flask/tests/test_labeler.py b/instrumentation/opentelemetry-instrumentation-flask/tests/test_labeler.py new file mode 100644 index 0000000000..9232262b46 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-flask/tests/test_labeler.py @@ -0,0 +1,189 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from timeit import default_timer +from unittest.mock import patch + +import flask + +from opentelemetry.instrumentation._labeler import ( + clear_labeler, + get_labeler, +) +from opentelemetry.instrumentation._semconv import ( + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, + _server_active_requests_count_attrs_new, + _server_active_requests_count_attrs_old, + _server_duration_attrs_new, + _server_duration_attrs_old, +) +from opentelemetry.instrumentation.flask import FlaskInstrumentor +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + NumberDataPoint, +) +from opentelemetry.test.wsgitestutil import WsgiTestBase + +# pylint: disable=import-error +from .base_test import InstrumentationTest + + +_expected_metric_names_old = [ + "http.server.active_requests", + "http.server.duration", +] +_expected_metric_names_new = [ + "http.server.active_requests", + "http.server.request.duration", +] + +_custom_attributes = [ + "custom_attr", "endpoint_type", "feature_flag" +] + +_server_duration_attrs_old_with_custom = _server_duration_attrs_old.copy() +_server_duration_attrs_old_with_custom.append("http.target") +_server_duration_attrs_old_with_custom.extend(_custom_attributes) +_server_duration_attrs_new_with_custom = _server_duration_attrs_new.copy() +_server_duration_attrs_new_with_custom.append("http.route") +_server_duration_attrs_new_with_custom.extend(_custom_attributes) + +_recommended_metrics_attrs_old_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_old, + "http.server.duration": _server_duration_attrs_old_with_custom, +} +_recommended_metrics_attrs_new_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_new, + "http.server.request.duration": _server_duration_attrs_new_with_custom, +} + + +class TestFlaskLabeler(InstrumentationTest, WsgiTestBase): + def setUp(self): + super().setUp() + + test_name = "" + if hasattr(self, "_testMethodName"): + test_name = self._testMethodName + sem_conv_mode = "default" + if "new_semconv" in test_name: + sem_conv_mode = "http" + self.env_patch = patch.dict( + "os.environ", + { + OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode, + }, + ) + _OpenTelemetrySemanticConventionStability._initialized = False + self.env_patch.start() + + clear_labeler() + self.app = flask.Flask(__name__) + + @self.app.route("/test_labeler") + def test_labeler_route(): + labeler = get_labeler() + labeler.add("custom_attr", "test_value") + labeler.add_attributes({ + "endpoint_type": "test", + "feature_flag": True + }) + return "OK" + + @self.app.route("/no_labeler") + def test_no_labeler_route(): + return "No labeler" + + FlaskInstrumentor().instrument_app(self.app) + self._common_initialization() + + def tearDown(self): + super().tearDown() + clear_labeler() + with self.disable_logging(): + FlaskInstrumentor().uninstrument_app(self.app) + + def test_flask_metrics_custom_attributes(self): + start = default_timer() + self.client.get("/test_labeler") + self.client.get("/test_labeler") + self.client.get("/test_labeler") + duration = max(round((default_timer() - start) * 1000), 0) + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histogram_data_point_seen = False + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names_old) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + self.assertAlmostEqual( + duration, point.sum, delta=10 + ) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_old_with_custom[metric.name], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + + def test_flask_metrics_custom_attributes_new_semconv(self): + start = default_timer() + self.client.get("/test_labeler") + self.client.get("/test_labeler") + self.client.get("/test_labeler") + duration_s = max(default_timer() - start, 0) + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histogram_data_point_seen = False + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names_new) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + self.assertAlmostEqual( + duration_s, point.sum, places=1 + ) + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_new_with_custom[metric.name], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) \ No newline at end of file From ffcf998cc9d7e421cb37324c6648619f342fd3dc Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 22 Aug 2025 15:03:06 -0700 Subject: [PATCH 12/42] Mv Flask attrs test to existing test_programmatic --- .../tests/test_labeler.py | 189 ------------------ .../tests/test_programmatic.py | 113 +++++++++++ 2 files changed, 113 insertions(+), 189 deletions(-) delete mode 100644 instrumentation/opentelemetry-instrumentation-flask/tests/test_labeler.py diff --git a/instrumentation/opentelemetry-instrumentation-flask/tests/test_labeler.py b/instrumentation/opentelemetry-instrumentation-flask/tests/test_labeler.py deleted file mode 100644 index 9232262b46..0000000000 --- a/instrumentation/opentelemetry-instrumentation-flask/tests/test_labeler.py +++ /dev/null @@ -1,189 +0,0 @@ -# Copyright The OpenTelemetry Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from timeit import default_timer -from unittest.mock import patch - -import flask - -from opentelemetry.instrumentation._labeler import ( - clear_labeler, - get_labeler, -) -from opentelemetry.instrumentation._semconv import ( - HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, - OTEL_SEMCONV_STABILITY_OPT_IN, - _OpenTelemetrySemanticConventionStability, - _server_active_requests_count_attrs_new, - _server_active_requests_count_attrs_old, - _server_duration_attrs_new, - _server_duration_attrs_old, -) -from opentelemetry.instrumentation.flask import FlaskInstrumentor -from opentelemetry.sdk.metrics.export import ( - HistogramDataPoint, - NumberDataPoint, -) -from opentelemetry.test.wsgitestutil import WsgiTestBase - -# pylint: disable=import-error -from .base_test import InstrumentationTest - - -_expected_metric_names_old = [ - "http.server.active_requests", - "http.server.duration", -] -_expected_metric_names_new = [ - "http.server.active_requests", - "http.server.request.duration", -] - -_custom_attributes = [ - "custom_attr", "endpoint_type", "feature_flag" -] - -_server_duration_attrs_old_with_custom = _server_duration_attrs_old.copy() -_server_duration_attrs_old_with_custom.append("http.target") -_server_duration_attrs_old_with_custom.extend(_custom_attributes) -_server_duration_attrs_new_with_custom = _server_duration_attrs_new.copy() -_server_duration_attrs_new_with_custom.append("http.route") -_server_duration_attrs_new_with_custom.extend(_custom_attributes) - -_recommended_metrics_attrs_old_with_custom = { - "http.server.active_requests": _server_active_requests_count_attrs_old, - "http.server.duration": _server_duration_attrs_old_with_custom, -} -_recommended_metrics_attrs_new_with_custom = { - "http.server.active_requests": _server_active_requests_count_attrs_new, - "http.server.request.duration": _server_duration_attrs_new_with_custom, -} - - -class TestFlaskLabeler(InstrumentationTest, WsgiTestBase): - def setUp(self): - super().setUp() - - test_name = "" - if hasattr(self, "_testMethodName"): - test_name = self._testMethodName - sem_conv_mode = "default" - if "new_semconv" in test_name: - sem_conv_mode = "http" - self.env_patch = patch.dict( - "os.environ", - { - OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode, - }, - ) - _OpenTelemetrySemanticConventionStability._initialized = False - self.env_patch.start() - - clear_labeler() - self.app = flask.Flask(__name__) - - @self.app.route("/test_labeler") - def test_labeler_route(): - labeler = get_labeler() - labeler.add("custom_attr", "test_value") - labeler.add_attributes({ - "endpoint_type": "test", - "feature_flag": True - }) - return "OK" - - @self.app.route("/no_labeler") - def test_no_labeler_route(): - return "No labeler" - - FlaskInstrumentor().instrument_app(self.app) - self._common_initialization() - - def tearDown(self): - super().tearDown() - clear_labeler() - with self.disable_logging(): - FlaskInstrumentor().uninstrument_app(self.app) - - def test_flask_metrics_custom_attributes(self): - start = default_timer() - self.client.get("/test_labeler") - self.client.get("/test_labeler") - self.client.get("/test_labeler") - duration = max(round((default_timer() - start) * 1000), 0) - metrics_list = self.memory_metrics_reader.get_metrics_data() - number_data_point_seen = False - histogram_data_point_seen = False - self.assertTrue(len(metrics_list.resource_metrics) != 0) - for resource_metric in metrics_list.resource_metrics: - self.assertTrue(len(resource_metric.scope_metrics) != 0) - for scope_metric in resource_metric.scope_metrics: - self.assertTrue(len(scope_metric.metrics) != 0) - for metric in scope_metric.metrics: - self.assertIn(metric.name, _expected_metric_names_old) - data_points = list(metric.data.data_points) - self.assertEqual(len(data_points), 1) - for point in data_points: - if isinstance(point, HistogramDataPoint): - self.assertEqual(point.count, 3) - self.assertAlmostEqual( - duration, point.sum, delta=10 - ) - histogram_data_point_seen = True - if isinstance(point, NumberDataPoint): - number_data_point_seen = True - for attr in point.attributes: - self.assertIn( - attr, - _recommended_metrics_attrs_old_with_custom[metric.name], - ) - self.assertTrue(number_data_point_seen and histogram_data_point_seen) - - def test_flask_metrics_custom_attributes_new_semconv(self): - start = default_timer() - self.client.get("/test_labeler") - self.client.get("/test_labeler") - self.client.get("/test_labeler") - duration_s = max(default_timer() - start, 0) - metrics_list = self.memory_metrics_reader.get_metrics_data() - number_data_point_seen = False - histogram_data_point_seen = False - self.assertTrue(len(metrics_list.resource_metrics) != 0) - for resource_metric in metrics_list.resource_metrics: - self.assertTrue(len(resource_metric.scope_metrics) != 0) - for scope_metric in resource_metric.scope_metrics: - self.assertTrue(len(scope_metric.metrics) != 0) - for metric in scope_metric.metrics: - self.assertIn(metric.name, _expected_metric_names_new) - data_points = list(metric.data.data_points) - self.assertEqual(len(data_points), 1) - for point in data_points: - if isinstance(point, HistogramDataPoint): - self.assertEqual(point.count, 3) - self.assertAlmostEqual( - duration_s, point.sum, places=1 - ) - self.assertEqual( - point.explicit_bounds, - HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, - ) - histogram_data_point_seen = True - if isinstance(point, NumberDataPoint): - number_data_point_seen = True - for attr in point.attributes: - self.assertIn( - attr, - _recommended_metrics_attrs_new_with_custom[metric.name], - ) - self.assertTrue(number_data_point_seen and histogram_data_point_seen) \ No newline at end of file diff --git a/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py b/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py index afcf750e0e..31b4434329 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py +++ b/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py @@ -19,6 +19,10 @@ from flask import Flask, request from opentelemetry import trace +from opentelemetry.instrumentation._labeler import ( + clear_labeler, + get_labeler, +) from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, OTEL_SEMCONV_STABILITY_OPT_IN, @@ -120,6 +124,23 @@ def expected_attributes_new(override_attributes): "http.server.request.duration": _server_duration_attrs_new_copy, } +_custom_attributes = ["custom_attr", "endpoint_type", "feature_flag"] +_server_duration_attrs_old_with_custom = _server_duration_attrs_old.copy() +_server_duration_attrs_old_with_custom.append("http.target") +_server_duration_attrs_old_with_custom.extend(_custom_attributes) +_server_duration_attrs_new_with_custom = _server_duration_attrs_new.copy() +_server_duration_attrs_new_with_custom.append("http.route") +_server_duration_attrs_new_with_custom.extend(_custom_attributes) + +_recommended_metrics_attrs_old_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_old, + "http.server.duration": _server_duration_attrs_old_with_custom, +} +_recommended_metrics_attrs_new_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_new, + "http.server.request.duration": _server_duration_attrs_new_with_custom, +} + # pylint: disable=too-many-public-methods class TestProgrammatic(InstrumentationTest, WsgiTestBase): @@ -151,7 +172,23 @@ def setUp(self): ) self.exclude_patch.start() + clear_labeler() + self.app = Flask(__name__) + + @self.app.route("/test_labeler") + def test_labeler_route(): + labeler = get_labeler() + labeler.add("custom_attr", "test_value") + labeler.add_attributes( + {"endpoint_type": "test", "feature_flag": True} + ) + return "OK" + + @self.app.route("/no_labeler") + def test_no_labeler_route(): + return "No labeler" + FlaskInstrumentor().instrument_app(self.app) self._common_initialization() @@ -499,6 +536,42 @@ def test_flask_metrics(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_flask_metrics_custom_attributes(self): + start = default_timer() + self.client.get("/test_labeler") + self.client.get("/test_labeler") + self.client.get("/test_labeler") + duration = max(round((default_timer() - start) * 1000), 0) + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histogram_data_point_seen = False + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names_old) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + self.assertAlmostEqual( + duration, point.sum, delta=10 + ) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_old_with_custom[ + metric.name + ], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_flask_metrics_new_semconv(self): start = default_timer() self.client.get("/hello/123") @@ -537,6 +610,46 @@ def test_flask_metrics_new_semconv(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_flask_metrics_custom_attributes_new_semconv(self): + start = default_timer() + self.client.get("/test_labeler") + self.client.get("/test_labeler") + self.client.get("/test_labeler") + duration_s = max(default_timer() - start, 0) + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histogram_data_point_seen = False + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names_new) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + self.assertAlmostEqual( + duration_s, point.sum, places=1 + ) + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_new_with_custom[ + metric.name + ], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_flask_metric_values(self): start = default_timer() self.client.post("/hello/756") From 7d24863d0c9e153f4c8c5769595d9fa3484da412 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 22 Aug 2025 15:23:37 -0700 Subject: [PATCH 13/42] Clarify Flask labeler effects --- .../src/opentelemetry/instrumentation/flask/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py index ba268bfc43..326990cbfa 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py @@ -98,7 +98,7 @@ def hello(): Custom Metrics Attributes using Labeler *************************************** The Flask instrumentation reads from a Labeler utility that supports adding custom attributes -to the HTTP metrics recorded by the instrumentation. +to the HTTP request duration metrics recorded by the instrumentation. .. code-block:: python From 74289b9d52e0f9281cf781759afab9bc3771ff04 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 22 Aug 2025 15:24:57 -0700 Subject: [PATCH 14/42] Changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f252e8290..62d99f1246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`: Add Labeler utility. Add FlaskInstrumentor support of custom attributes merging for HTTP duration metrics. + ([#3689](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3689)) + ## Version 1.36.0/0.57b0 (2025-07-29) ### Fixed From 3c731133cda8c5a48bdb611d6119b038d9ea45ae Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 22 Aug 2025 17:41:11 -0700 Subject: [PATCH 15/42] Add WsgiInstrumentor labeler support for custom metrics --- CHANGELOG.md | 2 +- .../instrumentation/wsgi/__init__.py | 42 +++++ .../tests/test_wsgi_middleware.py | 156 ++++++++++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4fc796303..e33f98a950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `opentelemetry-instrumentation-confluent-kafka` Add support for confluent-kafka <=2.11.0 ([#3685](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3685)) -- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`: Add Labeler utility. Add FlaskInstrumentor support of custom attributes merging for HTTP duration metrics. +- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`, `opentelemetry-instrumentation-wsgi`: Add Labeler utility. Add FlaskInstrumentor, WsgiInstrumentor support of custom attributes merging for HTTP duration metrics. ([#3689](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3689)) ## Version 1.36.0/0.57b0 (2025-07-29) diff --git a/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py index ecbc256287..d405dc3a3f 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py @@ -79,6 +79,39 @@ def GET(self): ) server.start() +Custom Metrics Attributes using Labeler +*************************************** +The WSGI instrumentation reads from a Labeler utility that supports adding custom attributes +to the HTTP duration metrics recorded by the instrumentation. + + +.. code-block:: python + + from flask import Flask + from opentelemetry.instrumentation._labeler import get_labeler + from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware + + app = Flask(__name__) + app.wsgi_app = OpenTelemetryMiddleware(app.wsgi_app) + + @app.route("/user/") + def user_profile(user_id): + # Get the labeler for the current request + labeler = get_labeler() + # Add custom attributes to WSGI instrumentation metrics + labeler.add("user_id", user_id) + labeler.add("user_type", "registered") + # Or, add multiple attributes at once + labeler.add_attributes({ + "feature_flag": "new_ui", + "experiment_group": "control" + }) + return f"User profile for {user_id}" + + if __name__ == "__main__": + app.run(debug=True) + + Configuration ------------- @@ -223,6 +256,7 @@ def response_hook(span: Span, environ: WSGIEnvironment, status: str, response_he from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, TypeVar, cast from opentelemetry import context, trace +from opentelemetry.instrumentation._labeler import enhance_metric_attributes from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, _filter_semconv_active_request_count_attr, @@ -712,6 +746,10 @@ def __call__( duration_attrs_old = _parse_duration_attrs( req_attrs, _StabilityMode.DEFAULT ) + # Enhance attributes with any custom labeler attributes + duration_attrs_old = enhance_metric_attributes( + duration_attrs_old + ) self.duration_histogram_old.record( max(round(duration_s * 1000), 0), duration_attrs_old ) @@ -719,6 +757,10 @@ def __call__( duration_attrs_new = _parse_duration_attrs( req_attrs, _StabilityMode.HTTP ) + # Enhance attributes with any custom labeler attributes + duration_attrs_new = enhance_metric_attributes( + duration_attrs_new + ) self.duration_histogram_new.record( max(duration_s, 0), duration_attrs_new ) diff --git a/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py b/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py index 5a6e2d21f7..4af6fc94b2 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py @@ -22,6 +22,10 @@ import opentelemetry.instrumentation.wsgi as otel_wsgi from opentelemetry import trace as trace_api +from opentelemetry.instrumentation._labeler import ( + clear_labeler, + get_labeler, +) from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, OTEL_SEMCONV_STABILITY_OPT_IN, @@ -139,6 +143,14 @@ def error_wsgi_unhandled(environ, start_response): raise ValueError +def error_wsgi_unhandled_custom_attrs(environ, start_response): + labeler = get_labeler() + labeler.add("custom_attr", "test_value") + labeler.add_attributes({"endpoint_type": "test", "feature_flag": True}) + assert isinstance(environ, dict) + raise ValueError + + def wsgi_with_custom_response_headers(environ, start_response): assert isinstance(environ, dict) start_response( @@ -201,6 +213,28 @@ def wsgi_with_repeat_custom_response_headers(environ, start_response): "http.server.request.duration": _server_duration_attrs_new, } +_custom_attributes = ["custom_attr", "endpoint_type", "feature_flag"] +_server_duration_attrs_old_with_custom = _server_duration_attrs_old.copy() +_server_duration_attrs_old_with_custom.append("http.target") +_server_duration_attrs_old_with_custom.extend(_custom_attributes) +_server_duration_attrs_new_with_custom = _server_duration_attrs_new.copy() +_server_duration_attrs_new_with_custom.append("http.route") +_server_duration_attrs_new_with_custom.extend(_custom_attributes) + +_recommended_metrics_attrs_old_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_old, + "http.server.duration": _server_duration_attrs_old_with_custom, +} +_recommended_metrics_attrs_new_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_new, + "http.server.request.duration": _server_duration_attrs_new_with_custom, +} +_recommended_metrics_attrs_both_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_both, + "http.server.duration": _server_duration_attrs_old_with_custom, + "http.server.request.duration": _server_duration_attrs_new_with_custom, +} + class TestWsgiApplication(WsgiTestBase): def setUp(self): @@ -221,6 +255,8 @@ def setUp(self): }, ) + clear_labeler() + _OpenTelemetrySemanticConventionStability._initialized = False self.env_patch.start() @@ -415,6 +451,41 @@ def test_wsgi_metrics(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_wsgi_metrics_custom_attributes(self): + app = otel_wsgi.OpenTelemetryMiddleware( + error_wsgi_unhandled_custom_attrs + ) + self.assertRaises(ValueError, app, self.environ, self.start_response) + self.assertRaises(ValueError, app, self.environ, self.start_response) + self.assertRaises(ValueError, app, self.environ, self.start_response) + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histogram_data_point_seen = False + + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names_old) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_old_with_custom[ + metric.name + ], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_wsgi_metrics_new_semconv(self): # pylint: disable=too-many-nested-blocks app = otel_wsgi.OpenTelemetryMiddleware(error_wsgi_unhandled) @@ -452,6 +523,45 @@ def test_wsgi_metrics_new_semconv(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_wsgi_metrics_new_semconv_custom_attributes(self): + # pylint: disable=too-many-nested-blocks + app = otel_wsgi.OpenTelemetryMiddleware(error_wsgi_unhandled) + self.assertRaises(ValueError, app, self.environ, self.start_response) + self.assertRaises(ValueError, app, self.environ, self.start_response) + self.assertRaises(ValueError, app, self.environ, self.start_response) + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histogram_data_point_seen = False + + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names_new) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + if metric.name == "http.server.request.duration": + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_new_with_custom[ + metric.name + ], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_wsgi_metrics_both_semconv(self): # pylint: disable=too-many-nested-blocks app = otel_wsgi.OpenTelemetryMiddleware(error_wsgi_unhandled) @@ -496,6 +606,52 @@ def test_wsgi_metrics_both_semconv(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_wsgi_metrics_both_semconv_custom_attributes(self): + # pylint: disable=too-many-nested-blocks + app = otel_wsgi.OpenTelemetryMiddleware(error_wsgi_unhandled) + self.assertRaises(ValueError, app, self.environ, self.start_response) + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histogram_data_point_seen = False + + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + for metric in scope_metric.metrics: + if metric.unit == "ms": + self.assertEqual(metric.name, "http.server.duration") + elif metric.unit == "s": + self.assertEqual( + metric.name, "http.server.request.duration" + ) + else: + self.assertEqual( + metric.name, "http.server.active_requests" + ) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 1) + if metric.name == "http.server.request.duration": + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_both_with_custom[ + metric.name + ], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_nonstandard_http_method(self): self.environ["REQUEST_METHOD"] = "NONSTANDARD" app = otel_wsgi.OpenTelemetryMiddleware(simple_wsgi) From 8cd8c81f7e38c9176198183964a13129dbf86a47 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 22 Aug 2025 18:08:16 -0700 Subject: [PATCH 16/42] Add DjangoInstrumentor labeler custom attrs metrics support --- CHANGELOG.md | 2 +- .../instrumentation/django/__init__.py | 6 + .../django/middleware/otel_middleware.py | 9 + .../tests/test_middleware.py | 224 ++++++++++++++++++ .../tests/views.py | 9 + 5 files changed, 249 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e33f98a950..0fa48b3ef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `opentelemetry-instrumentation-confluent-kafka` Add support for confluent-kafka <=2.11.0 ([#3685](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3685)) -- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`, `opentelemetry-instrumentation-wsgi`: Add Labeler utility. Add FlaskInstrumentor, WsgiInstrumentor support of custom attributes merging for HTTP duration metrics. +- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`, `opentelemetry-instrumentation-wsgi`, `opentelemetry-instrumentation-django`: Add Labeler utility. Add FlaskInstrumentor, DjangoInstrumentor, WsgiInstrumentor support of custom attributes merging for HTTP duration metrics. ([#3689](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3689)) ## Version 1.36.0/0.57b0 (2025-07-29) diff --git a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py index 7d92aff145..8f45ff6645 100644 --- a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py @@ -232,6 +232,12 @@ def response_hook(span, request, response): Note: The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change. + +Custom Metrics Attributes using Labeler +*************************************** +The Django instrumentation reads from a Labeler utility that supports adding custom attributes to the HTTP duration metrics recorded by the instrumentation. + + API --- diff --git a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py index f607046959..6c812d2ec6 100644 --- a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py @@ -22,6 +22,7 @@ from django.http import HttpRequest, HttpResponse from opentelemetry.context import detach +from opentelemetry.instrumentation._labeler import enhance_metric_attributes from opentelemetry.instrumentation._semconv import ( _filter_semconv_active_request_count_attr, _filter_semconv_duration_attrs, @@ -436,6 +437,10 @@ def process_response(self, request, response): target = duration_attrs.get(SpanAttributes.HTTP_TARGET) if target: duration_attrs_old[SpanAttributes.HTTP_TARGET] = target + # Enhance attributes with any custom labeler attributes + duration_attrs_old = enhance_metric_attributes( + duration_attrs_old + ) self._duration_histogram_old.record( max(round(duration_s * 1000), 0), duration_attrs_old ) @@ -443,6 +448,10 @@ def process_response(self, request, response): duration_attrs_new = _parse_duration_attrs( duration_attrs, _StabilityMode.HTTP ) + # Enhance attributes with any custom labeler attributes + duration_attrs_new = enhance_metric_attributes( + duration_attrs_new + ) self._duration_histogram_new.record( max(duration_s, 0), duration_attrs_new ) diff --git a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py index 960bf97bc4..3c7ea00e14 100644 --- a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py @@ -25,6 +25,7 @@ from django.test.utils import setup_test_environment, teardown_test_environment from opentelemetry import trace +from opentelemetry.instrumentation._labeler import clear_labeler from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, @@ -69,6 +70,7 @@ excluded_noarg2, response_with_custom_header, route_span_name, + route_span_name_custom_attributes, traced, traced_template, ) @@ -95,6 +97,10 @@ def path(path_argument, *args, **kwargs): re_path(r"^excluded_noarg/", excluded_noarg), re_path(r"^excluded_noarg2/", excluded_noarg2), re_path(r"^span_name/([0-9]{4})/$", route_span_name), + re_path( + r"^span_name_custom_attrs/([0-9]{4})/$", + route_span_name_custom_attributes, + ), path("", traced, name="empty"), ] _django_instrumentor = DjangoInstrumentor() @@ -115,6 +121,7 @@ def setUpClass(cls): def setUp(self): super().setUp() + clear_labeler() setup_test_environment() test_name = "" if hasattr(self, "_testMethodName"): @@ -770,6 +777,67 @@ def test_wsgi_metrics(self): ) self.assertTrue(histrogram_data_point_seen and number_data_point_seen) + def test_wsgi_metrics_custom_attributes(self): + _expected_metric_names = [ + "http.server.active_requests", + "http.server.duration", + ] + expected_duration_attributes = { + "http.method": "GET", + "http.scheme": "http", + "http.flavor": "1.1", + "http.server_name": "testserver", + "net.host.port": 80, + "http.status_code": 200, + "http.target": "^span_name_custom_attrs/([0-9]{4})/$", + "custom_attr": "test_value", + "endpoint_type": "test", + "feature_flag": True, + } + expected_requests_count_attributes = { + "http.method": "GET", + "http.scheme": "http", + "http.flavor": "1.1", + "http.server_name": "testserver", + } + start = default_timer() + for _ in range(3): + response = Client().get("/span_name_custom_attrs/1234/") + self.assertEqual(response.status_code, 200) + duration = max(round((default_timer() - start) * 1000), 0) + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histrogram_data_point_seen = False + + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + histrogram_data_point_seen = True + self.assertAlmostEqual( + duration, point.sum, delta=100 + ) + self.assertDictEqual( + expected_duration_attributes, + dict(point.attributes), + ) + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + self.assertEqual(point.value, 0) + self.assertDictEqual( + expected_requests_count_attributes, + dict(point.attributes), + ) + self.assertTrue(histrogram_data_point_seen and number_data_point_seen) + # pylint: disable=too-many-locals def test_wsgi_metrics_new_semconv(self): _expected_metric_names = [ @@ -829,6 +897,68 @@ def test_wsgi_metrics_new_semconv(self): ) self.assertTrue(histrogram_data_point_seen and number_data_point_seen) + # pylint: disable=too-many-locals + def test_wsgi_metrics_new_semconv_custom_attributes(self): + _expected_metric_names = [ + "http.server.active_requests", + "http.server.request.duration", + ] + expected_duration_attributes = { + "http.request.method": "GET", + "url.scheme": "http", + "network.protocol.version": "1.1", + "http.response.status_code": 200, + "http.route": "^span_name_custom_attrs/([0-9]{4})/$", + "custom_attr": "test_value", + "endpoint_type": "test", + "feature_flag": True, + } + expected_requests_count_attributes = { + "http.request.method": "GET", + "url.scheme": "http", + } + start = default_timer() + for _ in range(3): + response = Client().get("/span_name_custom_attrs/1234/") + self.assertEqual(response.status_code, 200) + duration_s = default_timer() - start + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histrogram_data_point_seen = False + + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + histrogram_data_point_seen = True + self.assertAlmostEqual( + duration_s, point.sum, places=1 + ) + self.assertDictEqual( + expected_duration_attributes, + dict(point.attributes), + ) + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + self.assertEqual(point.value, 0) + self.assertDictEqual( + expected_requests_count_attributes, + dict(point.attributes), + ) + self.assertTrue(histrogram_data_point_seen and number_data_point_seen) + # pylint: disable=too-many-locals # pylint: disable=too-many-nested-blocks def test_wsgi_metrics_both_semconv(self): @@ -917,6 +1047,100 @@ def test_wsgi_metrics_both_semconv(self): ) self.assertTrue(histrogram_data_point_seen and number_data_point_seen) + # pylint: disable=too-many-locals + # pylint: disable=too-many-nested-blocks + def test_wsgi_metrics_both_semconv_custom_attributes(self): + _expected_metric_names = [ + "http.server.duration", + "http.server.active_requests", + "http.server.request.duration", + ] + expected_duration_attributes_old = { + "http.method": "GET", + "http.scheme": "http", + "http.flavor": "1.1", + "http.server_name": "testserver", + "net.host.port": 80, + "http.status_code": 200, + "http.target": "^span_name_custom_attrs/([0-9]{4})/$", + "custom_attr": "test_value", + "endpoint_type": "test", + "feature_flag": True, + } + expected_duration_attributes_new = { + "http.request.method": "GET", + "url.scheme": "http", + "network.protocol.version": "1.1", + "http.response.status_code": 200, + "http.route": "^span_name_custom_attrs/([0-9]{4})/$", + "custom_attr": "test_value", + "endpoint_type": "test", + "feature_flag": True, + } + expected_requests_count_attributes = { + "http.method": "GET", + "http.scheme": "http", + "http.flavor": "1.1", + "http.server_name": "testserver", + "http.request.method": "GET", + "url.scheme": "http", + } + start = default_timer() + for _ in range(3): + response = Client().get("/span_name/1234/") + self.assertEqual(response.status_code, 200) + duration_s = max(default_timer() - start, 0) + duration = max(round(duration_s * 1000), 0) + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histrogram_data_point_seen = False + + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + histrogram_data_point_seen = True + if metric.name == "http.server.request.duration": + self.assertAlmostEqual( + duration_s, point.sum, places=1 + ) + self.assertDictEqual( + expected_duration_attributes_new, + dict(point.attributes), + ) + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + elif metric.name == "http.server.duration": + self.assertAlmostEqual( + duration, point.sum, delta=100 + ) + self.assertDictEqual( + expected_duration_attributes_old, + dict(point.attributes), + ) + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, + ) + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + self.assertEqual(point.value, 0) + self.assertDictEqual( + expected_requests_count_attributes, + dict(point.attributes), + ) + self.assertTrue(histrogram_data_point_seen and number_data_point_seen) + def test_wsgi_metrics_unistrument(self): Client().get("/span_name/1234/") _django_instrumentor.uninstrument() diff --git a/instrumentation/opentelemetry-instrumentation-django/tests/views.py b/instrumentation/opentelemetry-instrumentation-django/tests/views.py index f2ede18b74..f505ca5000 100644 --- a/instrumentation/opentelemetry-instrumentation-django/tests/views.py +++ b/instrumentation/opentelemetry-instrumentation-django/tests/views.py @@ -1,5 +1,7 @@ from django.http import HttpResponse +from opentelemetry.instrumentation._labeler import get_labeler + def traced(request): # pylint: disable=unused-argument return HttpResponse() @@ -29,6 +31,13 @@ def route_span_name(request, *args, **kwargs): # pylint: disable=unused-argumen return HttpResponse() +def route_span_name_custom_attributes(request, *args, **kwargs): # pylint: disable=unused-argument + labeler = get_labeler() + labeler.add("custom_attr", "test_value") + labeler.add_attributes({"endpoint_type": "test", "feature_flag": True}) + return HttpResponse() + + def response_with_custom_header(request): response = HttpResponse() response["custom-test-header-1"] = "test-header-value-1" From ee45e0fcbb19f526ebf7a78f7341045ebe49af18 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 25 Aug 2025 09:41:10 -0700 Subject: [PATCH 17/42] Fix test --- .../tests/test_middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py index 3c7ea00e14..9159299109 100644 --- a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py @@ -1087,7 +1087,7 @@ def test_wsgi_metrics_both_semconv_custom_attributes(self): } start = default_timer() for _ in range(3): - response = Client().get("/span_name/1234/") + response = Client().get("/span_name_custom_attrs/1234/") self.assertEqual(response.status_code, 200) duration_s = max(default_timer() - start, 0) duration = max(round(duration_s * 1000), 0) From 4ef0c1a5509feb627af085a51ad5b5e54068a20f Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 25 Aug 2025 10:02:23 -0700 Subject: [PATCH 18/42] Docstring --- .../instrumentation/django/__init__.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py index 8f45ff6645..30d57523b0 100644 --- a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py @@ -237,6 +237,29 @@ def response_hook(span, request, response): *************************************** The Django instrumentation reads from a Labeler utility that supports adding custom attributes to the HTTP duration metrics recorded by the instrumentation. +.. code:: python + + from django.http import HttpResponse + from opentelemetry.instrumentation._labeler import get_labeler + from opentelemetry.instrumentation.django import DjangoInstrumentor + + DjangoInstrumentor().instrument() + + # For urlpattern `/user//` mapped elsewhere + def my_user_view(request, user_id): + # Get the labeler for the current request + labeler = get_labeler() + + # Add custom attributes to Flask instrumentation metrics + labeler.add("user_id", user_id) + labeler.add("user_type", "registered") + + # Or, add multiple attributes at once + labeler.add_attributes({ + "feature_flag": "new_ui", + "experiment_group": "control" + }) + return HttpResponse("Done!") API --- From 035ae2e2e8551110e518c43509bf79aa48dd4e27 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 25 Aug 2025 14:51:43 -0700 Subject: [PATCH 19/42] Add FalconInstrumentor labeler custom attrs metrics support --- CHANGELOG.md | 2 +- .../instrumentation/falcon/__init__.py | 37 +++++ .../tests/app.py | 20 +++ .../tests/test_falcon.py | 134 ++++++++++++++++-- 4 files changed, 184 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 636dc08881..8215ee16f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3666](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3666)) - `opentelemetry-sdk-extension-aws` Add AWS X-Ray Remote Sampler with initial Rules Poller implementation ([#3366](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3366)) -- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`, `opentelemetry-instrumentation-wsgi`, `opentelemetry-instrumentation-django`: Add Labeler utility. Add FlaskInstrumentor, DjangoInstrumentor, WsgiInstrumentor support of custom attributes merging for HTTP duration metrics. +- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`, `opentelemetry-instrumentation-wsgi`, `opentelemetry-instrumentation-django`, `opentelemetry-instrumentation-falcon`: Add Labeler utility. Add FalconInstrumentor, FlaskInstrumentor, DjangoInstrumentor, WsgiInstrumentor support of custom attributes merging for HTTP duration metrics. ([#3689](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3689)) ## Version 1.36.0/0.57b0 (2025-07-29) diff --git a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py index 9c670287aa..05bd16d4d9 100644 --- a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py @@ -180,6 +180,38 @@ def response_hook(span, req, resp): Note: The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change. +Custom Metrics Attributes using Labeler +*************************************** +The Falcon instrumentation reads from a Labeler utility that supports adding custom attributes +to the HTTP duration metrics recorded by the instrumentation. + + +.. code-block:: python + + import falcon + from opentelemetry.instrumentation._labeler import get_labeler + from opentelemetry.instrumentation.falcon import FalconInstrumentor + + FalconInstrumentor().instrument() + + app = falcon.App() + + class UserProfileResource: + def on_get(self, req, resp, user_id): + # Get the labeler for the current request + labeler = get_labeler() + # Add custom attributes to Falcon instrumentation metrics + labeler.add("user_id", user_id) + labeler.add("user_type", "registered") + # Or, add multiple attributes at once + labeler.add_attributes({ + "feature_flag": "new_ui", + "experiment_group": "control" + }) + resp.text = f'User profile for {user_id}' + + app.add_route('/user/{user_id}', UserProfileResource()) + API --- """ @@ -195,6 +227,7 @@ def response_hook(span, req, resp): import opentelemetry.instrumentation.wsgi as otel_wsgi from opentelemetry import context, trace +from opentelemetry.instrumentation._labeler import enhance_metric_attributes from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, _get_schema_url, @@ -420,6 +453,8 @@ def _start_response(status, response_headers, *args, **kwargs): duration_attrs = otel_wsgi._parse_duration_attrs( attributes, _StabilityMode.DEFAULT ) + # Enhance attributes with any custom labeler attributes + duration_attrs = enhance_metric_attributes(duration_attrs) self.duration_histogram_old.record( max(round(duration_s * 1000), 0), duration_attrs ) @@ -427,6 +462,8 @@ def _start_response(status, response_headers, *args, **kwargs): duration_attrs = otel_wsgi._parse_duration_attrs( attributes, _StabilityMode.HTTP ) + # Enhance attributes with any custom labeler attributes + duration_attrs = enhance_metric_attributes(duration_attrs) self.duration_histogram_new.record( max(duration_s, 0), duration_attrs ) diff --git a/instrumentation/opentelemetry-instrumentation-falcon/tests/app.py b/instrumentation/opentelemetry-instrumentation-falcon/tests/app.py index 416ac80dff..819c914cc0 100644 --- a/instrumentation/opentelemetry-instrumentation-falcon/tests/app.py +++ b/instrumentation/opentelemetry-instrumentation-falcon/tests/app.py @@ -1,6 +1,10 @@ import falcon from packaging import version as package_version +from opentelemetry.instrumentation._labeler import ( + get_labeler, +) + # pylint:disable=R0201,W0613,E0602 @@ -75,6 +79,21 @@ def on_get(self, req, resp, user_id): resp.text = f"Hello user {user_id}" +class UserLabelerResource: + def on_get(self, req, resp, user_id): + labeler = get_labeler() + labeler.add("custom_attr", "test_value") + labeler.add_attributes({"endpoint_type": "test", "feature_flag": True}) + # pylint: disable=no-member + resp.status = falcon.HTTP_200 + + if _parsed_falcon_version < package_version.parse("3.0.0"): + # Falcon 1 and Falcon 2 + resp.body = f"Hello user {user_id}" + else: + resp.text = f"Hello user {user_id}" + + def make_app(): if _parsed_falcon_version < package_version.parse("3.0.0"): # Falcon 1 and Falcon 2 @@ -90,5 +109,6 @@ def make_app(): "/test_custom_response_headers", CustomResponseHeaderResource() ) app.add_route("/user/{user_id}", UserResource()) + app.add_route("/user_custom_attr/{user_id}", UserLabelerResource()) return app diff --git a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py index bf41fb020c..dd777d17c7 100644 --- a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py +++ b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py @@ -21,6 +21,7 @@ from packaging import version as package_version from opentelemetry import trace +from opentelemetry.instrumentation._labeler import clear_labeler from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, OTEL_SEMCONV_STABILITY_OPT_IN, @@ -103,12 +104,35 @@ "http.server.request.duration": _server_duration_attrs_new, } +_custom_attributes = ["custom_attr", "endpoint_type", "feature_flag"] +_server_duration_attrs_old_with_custom = _server_duration_attrs_old.copy() +_server_duration_attrs_old_with_custom.append("http.target") +_server_duration_attrs_old_with_custom.extend(_custom_attributes) +_server_duration_attrs_new_with_custom = _server_duration_attrs_new.copy() +_server_duration_attrs_new_with_custom.append("http.route") +_server_duration_attrs_new_with_custom.extend(_custom_attributes) + +_recommended_metrics_attrs_old_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_old, + "http.server.duration": _server_duration_attrs_old_with_custom, +} +_recommended_metrics_attrs_new_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_new, + "http.server.request.duration": _server_duration_attrs_new_with_custom, +} +_recommended_metrics_attrs_both_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_both, + "http.server.duration": _server_duration_attrs_old_with_custom, + "http.server.request.duration": _server_duration_attrs_new_with_custom, +} + _parsed_falcon_version = package_version.parse(_falcon_version) class TestFalconBase(TestBase): def setUp(self): super().setUp() + clear_labeler() test_name = "" if hasattr(self, "_testMethodName"): @@ -544,6 +568,38 @@ def test_falcon_metrics(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_falcon_metrics_custom_attributes(self): + self.client().simulate_get("/user_custom_attr/123") + self.client().simulate_get("/user_custom_attr/123") + self.client().simulate_get("/user_custom_attr/123") + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histogram_data_point_seen = False + + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_old_with_custom[ + metric.name + ], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_falcon_metric_values_new_semconv(self): number_data_point_seen = False histogram_data_point_seen = False @@ -580,6 +636,43 @@ def test_falcon_metric_values_new_semconv(self): self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_falcon_metric_values_new_semconv_custom_attributes(self): + number_data_point_seen = False + histogram_data_point_seen = False + + start = default_timer() + self.client().simulate_get("/user_custom_attr/123") + duration = max(default_timer() - start, 0) + + metrics_list = self.memory_metrics_reader.get_metrics_data() + for resource_metric in metrics_list.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 1) + histogram_data_point_seen = True + self.assertAlmostEqual( + duration, point.sum, delta=10 + ) + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + if isinstance(point, NumberDataPoint): + self.assertEqual(point.value, 0) + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_new_with_custom[ + metric.name + ], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_falcon_metric_values_both_semconv(self): number_data_point_seen = False histogram_data_point_seen = False @@ -635,34 +728,59 @@ def test_falcon_metric_values_both_semconv(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) - def test_falcon_metric_values(self): + def test_falcon_metric_values_both_semconv_custom_attributes(self): number_data_point_seen = False histogram_data_point_seen = False start = default_timer() - self.client().simulate_get("/hello/756") - duration = max(round((default_timer() - start) * 1000), 0) + self.client().simulate_get("/user_custom_attr/123") + duration_s = default_timer() - start metrics_list = self.memory_metrics_reader.get_metrics_data() + + # pylint: disable=too-many-nested-blocks for resource_metric in metrics_list.resource_metrics: for scope_metric in resource_metric.scope_metrics: for metric in scope_metric.metrics: + if metric.unit == "ms": + self.assertEqual(metric.name, "http.server.duration") + elif metric.unit == "s": + self.assertEqual( + metric.name, "http.server.request.duration" + ) + else: + self.assertEqual( + metric.name, "http.server.active_requests" + ) data_points = list(metric.data.data_points) self.assertEqual(len(data_points), 1) - for point in list(metric.data.data_points): + for point in data_points: if isinstance(point, HistogramDataPoint): self.assertEqual(point.count, 1) + if metric.unit == "ms": + self.assertAlmostEqual( + max(round(duration_s * 1000), 0), + point.sum, + delta=10, + ) + elif metric.unit == "s": + self.assertAlmostEqual( + max(duration_s, 0), point.sum, delta=10 + ) + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) histogram_data_point_seen = True - self.assertAlmostEqual( - duration, point.sum, delta=10 - ) if isinstance(point, NumberDataPoint): self.assertEqual(point.value, 0) number_data_point_seen = True for attr in point.attributes: self.assertIn( attr, - _recommended_metrics_attrs_old[metric.name], + _recommended_metrics_attrs_both_with_custom[ + metric.name + ], ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) From e50f1e24ae1c74e5703294c24d2a7f44dc5a1aea Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 25 Aug 2025 15:21:24 -0700 Subject: [PATCH 20/42] disable too-many-lints --- .../opentelemetry-instrumentation-falcon/tests/test_falcon.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py index dd777d17c7..aa403988a1 100644 --- a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py +++ b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py @@ -12,6 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. # + +# pylint: disable=too-many-lines + from timeit import default_timer from unittest.mock import Mock, patch From 03e767c62097570e4af0902ffb021ea2e9a4e3de Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 25 Aug 2025 17:46:05 -0700 Subject: [PATCH 21/42] Add AsgiInstrumentor labeler custom attrs metrics support --- .../instrumentation/asgi/__init__.py | 42 ++++ .../tests/test_asgi_middleware.py | 208 ++++++++++++++++++ 2 files changed, 250 insertions(+) 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 7e72dbf11f..44dc7d3f02 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py @@ -204,6 +204,39 @@ def client_response_hook(span: Span, scope: Scope, message: dict[str, Any]): Note: The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change. +Custom Metrics Attributes using Labeler +*************************************** +The ASGI instrumentation reads from a Labeler utility that supports adding custom attributes +to the HTTP duration metrics recorded by the instrumentation. + +.. code-block:: python + + .. code-block:: python + + from quart import Quart + from opentelemetry.instrumentation._labeler import get_labeler + from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware + + app = Quart(__name__) + app.asgi_app = OpenTelemetryMiddleware(app.asgi_app) + + @app.route("/user/") + async def user_profile(user_id): + # Get the labeler for the current request + labeler = get_labeler() + # Add custom attributes to ASGI instrumentation metrics + labeler.add("user_id", user_id) + labeler.add("user_type", "registered") + # Or, add multiple attributes at once + labeler.add_attributes({ + "feature_flag": "new_ui", + "experiment_group": "control" + }) + return f"User profile for {user_id}" + + if __name__ == "__main__": + app.run(debug=True) + API --- """ @@ -220,6 +253,7 @@ def client_response_hook(span: Span, scope: Scope, message: dict[str, Any]): from asgiref.compatibility import guarantee_single_callable from opentelemetry import context, trace +from opentelemetry.instrumentation._labeler import enhance_metric_attributes from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, _filter_semconv_active_request_count_attr, @@ -782,11 +816,19 @@ async def __call__( duration_attrs_old = _parse_duration_attrs( attributes, _StabilityMode.DEFAULT ) + # Enhance attributes with any custom labeler attributes + duration_attrs_old = enhance_metric_attributes( + duration_attrs_old + ) if target: duration_attrs_old[SpanAttributes.HTTP_TARGET] = target duration_attrs_new = _parse_duration_attrs( attributes, _StabilityMode.HTTP ) + # Enhance attributes with any custom labeler attributes + duration_attrs_new = enhance_metric_attributes( + duration_attrs_new + ) if self.duration_histogram_old: self.duration_histogram_old.record( max(round(duration_s * 1000), 0), duration_attrs_old diff --git a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py index b8791cf730..3106f055d3 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py @@ -22,6 +22,7 @@ import opentelemetry.instrumentation.asgi as otel_asgi from opentelemetry import trace as trace_api +from opentelemetry.instrumentation._labeler import clear_labeler, get_labeler from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, OTEL_SEMCONV_STABILITY_OPT_IN, @@ -108,6 +109,43 @@ _server_active_requests_count_attrs_old ) +_server_active_requests_count_attrs_both = ( + _server_active_requests_count_attrs_old +) +_server_active_requests_count_attrs_both.extend( + _server_active_requests_count_attrs_new +) + +_custom_attributes = ["custom_attr", "endpoint_type", "feature_flag"] +_server_duration_attrs_old_with_custom = _server_duration_attrs_old.copy() +_server_duration_attrs_old_with_custom.append("http.target") +_server_duration_attrs_old_with_custom.extend(_custom_attributes) +_server_duration_attrs_new_with_custom = _server_duration_attrs_new.copy() +_server_duration_attrs_new_with_custom.append("http.route") +_server_duration_attrs_new_with_custom.extend(_custom_attributes) + +_recommended_metrics_attrs_old_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_old, + "http.server.duration": _server_duration_attrs_old_with_custom, + "http.server.request.size": _server_duration_attrs_old_with_custom, + "http.server.response.size": _server_duration_attrs_old_with_custom, +} +_recommended_metrics_attrs_new_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_new, + "http.server.request.duration": _server_duration_attrs_new_with_custom, + "http.server.request.body.size": _server_duration_attrs_new_with_custom, + "http.server.response.body.size": _server_duration_attrs_new_with_custom, +} +_recommended_metrics_attrs_both_with_custom = { + "http.server.active_requests": _server_active_requests_count_attrs_both, + "http.server.duration": _server_duration_attrs_old_with_custom, + "http.server.request.duration": _server_duration_attrs_new_with_custom, + "http.server.request.size": _server_duration_attrs_old_with_custom, + "http.server.request.body.size": _server_duration_attrs_new_with_custom, + "http.server.response.size": _server_duration_attrs_old_with_custom, + "http.server.response.body.size": _server_duration_attrs_new_with_custom, +} + _SIMULATED_BACKGROUND_TASK_EXECUTION_TIME_S = 0.01 @@ -254,6 +292,28 @@ async def background_execution_trailers_asgi(scope, receive, send): time.sleep(_SIMULATED_BACKGROUND_TASK_EXECUTION_TIME_S) +async def custom_attrs_asgi(scope, receive, send): + assert isinstance(scope, dict) + assert scope["type"] == "http" + labeler = get_labeler() + labeler.add("custom_attr", "test_value") + labeler.add_attributes({"endpoint_type": "test", "feature_flag": True}) + message = await receive() + scope["headers"] = [(b"content-length", b"128")] + if message.get("type") == "http.request": + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + [b"Content-Type", b"text/plain"], + [b"content-length", b"1024"], + ], + } + ) + await send({"type": "http.response.body", "body": b"*"}) + + async def error_asgi(scope, receive, send): assert isinstance(scope, dict) assert scope["type"] == "http" @@ -281,6 +341,7 @@ async def error_asgi(scope, receive, send): class TestAsgiApplication(AsyncAsgiTestBase): def setUp(self): super().setUp() + clear_labeler() test_name = "" if hasattr(self, "_testMethodName"): @@ -1245,6 +1306,57 @@ async def test_asgi_metrics(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + # pylint: disable=too-many-nested-blocks + async def test_asgi_metrics_custom_attributes(self): + app = otel_asgi.OpenTelemetryMiddleware(custom_attrs_asgi) + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histogram_data_point_seen = False + + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + self.assertEqual( + scope_metric.scope.name, + "opentelemetry.instrumentation.asgi", + ) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names_old) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + histogram_data_point_seen = True + + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_old_with_custom[ + metric.name + ], + ) + + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + + for attr in point.attributes: + self.assertIn( + attr, _recommended_attrs_old[metric.name] + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + async def test_asgi_metrics_new_semconv(self): # pylint: disable=too-many-nested-blocks app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) @@ -1290,6 +1402,54 @@ async def test_asgi_metrics_new_semconv(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + async def test_asgi_metrics_new_semconv_custom_attributes(self): + # pylint: disable=too-many-nested-blocks + app = otel_asgi.OpenTelemetryMiddleware(custom_attrs_asgi) + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histogram_data_point_seen = False + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + self.assertEqual( + scope_metric.scope.name, + "opentelemetry.instrumentation.asgi", + ) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names_new) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + if metric.name == "http.server.request.duration": + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_new_with_custom[ + metric.name + ], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + async def test_asgi_metrics_both_semconv(self): # pylint: disable=too-many-nested-blocks app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) @@ -1335,6 +1495,54 @@ async def test_asgi_metrics_both_semconv(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + async def test_asgi_metrics_both_semconv_custom_attributes(self): + # pylint: disable=too-many-nested-blocks + app = otel_asgi.OpenTelemetryMiddleware(custom_attrs_asgi) + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + metrics_list = self.memory_metrics_reader.get_metrics_data() + number_data_point_seen = False + histogram_data_point_seen = False + self.assertTrue(len(metrics_list.resource_metrics) != 0) + for resource_metric in metrics_list.resource_metrics: + self.assertTrue(len(resource_metric.scope_metrics) != 0) + for scope_metric in resource_metric.scope_metrics: + self.assertTrue(len(scope_metric.metrics) != 0) + self.assertEqual( + scope_metric.scope.name, + "opentelemetry.instrumentation.asgi", + ) + for metric in scope_metric.metrics: + self.assertIn(metric.name, _expected_metric_names_both) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + if metric.name == "http.server.request.duration": + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_both_with_custom[ + metric.name + ], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + async def test_basic_metric_success(self): app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) self.seed_app(app) From 1b39798122274ff2ca4ecd26695f4b9862f621f0 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 25 Aug 2025 17:47:22 -0700 Subject: [PATCH 22/42] Changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8215ee16f9..fd9449daa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3666](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3666)) - `opentelemetry-sdk-extension-aws` Add AWS X-Ray Remote Sampler with initial Rules Poller implementation ([#3366](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3366)) -- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`, `opentelemetry-instrumentation-wsgi`, `opentelemetry-instrumentation-django`, `opentelemetry-instrumentation-falcon`: Add Labeler utility. Add FalconInstrumentor, FlaskInstrumentor, DjangoInstrumentor, WsgiInstrumentor support of custom attributes merging for HTTP duration metrics. +- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`, `opentelemetry-instrumentation-wsgi`, `opentelemetry-instrumentation-django`, `opentelemetry-instrumentation-falcon`, `opentelemetry-instrumentation-asgi`: Add Labeler utility. Add FalconInstrumentor, FlaskInstrumentor, DjangoInstrumentor, WsgiInstrumentor, AsgiInstrumentor support of custom attributes merging for HTTP duration metrics. ([#3689](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3689)) ## Version 1.36.0/0.57b0 (2025-07-29) From fc74620c6d69fe347b35c59e6e8416025f91eadd Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Tue, 26 Aug 2025 14:41:43 -0700 Subject: [PATCH 23/42] Fix asgi custom attributes --- .../src/opentelemetry/instrumentation/asgi/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 44dc7d3f02..1ffd309324 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py @@ -253,7 +253,10 @@ async def user_profile(user_id): from asgiref.compatibility import guarantee_single_callable from opentelemetry import context, trace -from opentelemetry.instrumentation._labeler import enhance_metric_attributes +from opentelemetry.instrumentation._labeler import ( + enhance_metric_attributes, + get_labeler, +) from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, _filter_semconv_active_request_count_attr, @@ -735,6 +738,9 @@ async def __call__( receive: An awaitable callable yielding dictionaries send: An awaitable callable taking a single dictionary as argument. """ + # Required to create new instance for custom attributes in async context + _ = get_labeler() + start = default_timer() if scope["type"] not in ("http", "websocket"): return await self.app(scope, receive, send) From cd5e639e6441bd1ca953c5e73608c56547901a5b Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Wed, 27 Aug 2025 15:21:48 -0700 Subject: [PATCH 24/42] Tidy Labeler --- .../instrumentation/_labeler/__init__.py | 19 +++++--------- .../_labeler/_internal/__init__.py | 25 ++++++------------- .../tests/test_labeler.py | 12 --------- 3 files changed, 13 insertions(+), 43 deletions(-) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py index c1679a8ba3..4f2a59fea1 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py @@ -16,7 +16,7 @@ OpenTelemetry Labeler ===================== -The labeler utility provides a way to add custom attributes to metrics generated by OpenTelemetry instrumentors. This supports enriching metrics with context-specific information available only after HTTP requests are received, at their origin rather than requiring downstream processing or additional metric calculations. +The labeler utility provides a way to add custom attributes to some metrics generated by some OpenTelemetry instrumentors. This was inspired by OpenTelemetry Go's net/http instrumentation Labeler https://github.com/open-telemetry/opentelemetry-go-contrib/pull/306 @@ -24,12 +24,12 @@ Usage ----- -The labeler works within the context of an instrumented request or operation. Use ``get_labeler()`` to obtain a labeler instance for the current context, then add attributes using the ``add()`` or ``add_attributes()`` methods. +The labeler works within the context of an instrumented request or operation. Use ``get_labeler`` to obtain a labeler instance for the current context, then add attributes using the ``add`` or ``add_attributes`` methods. Example with Flask ------------------ -Here's an example showing how to use the labeler with Flask instrumentation: +Here's an example showing how to use the labeler with programmatic Flask instrumentation: .. code-block:: python @@ -42,15 +42,6 @@ @app.route("/healthcheck") def healthcheck(): - # Get the labeler for the current request - labeler = get_labeler() - - labeler.add_attributes( - { - "endpoint_type": "healthcheck", - "internal_request": True, - } - ) return "OK" @app.route("/user/") @@ -70,7 +61,9 @@ def user_profile(user_id): return f"Got user profile for {user_id}" -The labeler also works with auto-instrumentation and those instrumentors that have implemented custom attributes support for metrics. +The labeler also works with auto-instrumentation. + +Custom attributes are merged by any instrumentors that use ``enhance_metric_attributes`` before their calls to report individual metrics recording, such as ``Histogram.record``. ``enchance_metrics_attributes`` does not overwrite base attributes that exist at the same keys. """ from opentelemetry.instrumentation._labeler._internal import ( diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py index 05ef79f934..e41157b201 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py @@ -41,11 +41,6 @@ def add(self, key: str, value: Union[str, int, float, bool]) -> None: key: The attribute key value: The attribute value (must be a primitive type) """ - if not isinstance(value, (str, int, float, bool)): - raise ValueError( - f"Attribute value must be str, int, float, or bool, got {type(value)}" - ) - with self._lock: self._attributes[key] = value @@ -58,12 +53,6 @@ def add_attributes( Args: attributes: Dictionary of attributes to add """ - for key, value in attributes.items(): - if not isinstance(value, (str, int, float, bool)): - raise ValueError( - f"Attribute value for '{key}' must be str, int, float, or bool, got {type(value)}" - ) - with self._lock: self._attributes.update(attributes) @@ -140,9 +129,9 @@ def enhance_metric_attributes( """ Combines base_attributes with custom attributes from the current labeler, returning a new dictionary of attributes. Custom attributes are skipped - if they would override base_attributes, exceed max_custom_attrs number, - or are not simple types (str, int, float, bool). If custom attributes - have string values exceeding the max_attr_value_length, then they are truncated. + if they would override base_attributes, or exceed max_custom_attrs number. + If custom attributes have string values exceeding the max_attr_value_length, + then they are truncated. Args: base_attributes: The base attributes for the metric @@ -151,7 +140,8 @@ def enhance_metric_attributes( max_attr_value_length: Maximum length for string attribute values Returns: - Dictionary combining base and custom attributes + Dictionary combining base and custom attributes. If no custom attributes, + returns a copy of the original base attributes. """ if not include_custom: return base_attributes.copy() @@ -172,8 +162,7 @@ def enhance_metric_attributes( if isinstance(value, str) and len(value) > max_attr_value_length: value = value[:max_attr_value_length] - if isinstance(value, (str, int, float, bool)): - enhanced_attributes[key] = value - added_count += 1 + enhanced_attributes[key] = value + added_count += 1 return enhanced_attributes diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py index 11b88a158f..66ea975c61 100644 --- a/opentelemetry-instrumentation/tests/test_labeler.py +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -61,18 +61,6 @@ def test_add_attributes_dict(self): attributes = labeler.get_attributes() self.assertEqual(attributes, attrs) - def test_invalid_attribute_types(self): - labeler = Labeler() - - with self.assertRaises(ValueError): - labeler.add("key", [1, 2, 3]) - - with self.assertRaises(ValueError): - labeler.add("key", {"nested": "dict"}) - - with self.assertRaises(ValueError): - labeler.add_attributes({"key": None}) - def test_overwrite_attribute(self): labeler = Labeler() labeler.add("key1", "original") From b33883359aafe802186f7cbe3eab63dbe92a1058 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Wed, 27 Aug 2025 15:36:42 -0700 Subject: [PATCH 25/42] Update doc --- .../instrumentation/wsgi/__init__.py | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py index d405dc3a3f..39427b140b 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py @@ -81,35 +81,48 @@ def GET(self): Custom Metrics Attributes using Labeler *************************************** -The WSGI instrumentation reads from a Labeler utility that supports adding custom attributes -to the HTTP duration metrics recorded by the instrumentation. - +The WSGI instrumentation reads from a labeler utility that supports adding custom attributes +to HTTP duration metrics at record time. .. code-block:: python - from flask import Flask + import web + from cheroot import wsgi + from opentelemetry.instrumentation._labeler import get_labeler from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware - app = Flask(__name__) - app.wsgi_app = OpenTelemetryMiddleware(app.wsgi_app) + urls = ( + '/', 'index', + '/users/(.+)/', 'user_profile' + ) + + class user_profile: + def GET(self, user_id): + # Get the labeler for the current request + labeler = get_labeler() + + # Add custom attributes to WSGI instrumentation metrics + labeler.add("user_id", user_id) + labeler.add("user_type", "registered") - @app.route("/user/") - def user_profile(user_id): - # Get the labeler for the current request - labeler = get_labeler() - # Add custom attributes to WSGI instrumentation metrics - labeler.add("user_id", user_id) - labeler.add("user_type", "registered") - # Or, add multiple attributes at once - labeler.add_attributes({ - "feature_flag": "new_ui", - "experiment_group": "control" - }) - return f"User profile for {user_id}" + # Or, add multiple attributes at once + labeler.add_attributes({ + "feature_flag": "new_ui", + "experiment_group": "control" + }) + return f"User profile for {user_id}" if __name__ == "__main__": - app.run(debug=True) + app = web.application(urls, globals()) + func = app.wsgifunc() + + func = OpenTelemetryMiddleware(func) + + server = wsgi.WSGIServer( + ("localhost", 5100), func, server_name="localhost" + ) + server.start() Configuration From aed659be0b00aef300d33bd2e261c14dbaa18fc1 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Wed, 27 Aug 2025 15:56:22 -0700 Subject: [PATCH 26/42] Update docstring --- .../opentelemetry/instrumentation/asgi/__init__.py | 13 ++++++++++--- .../instrumentation/django/__init__.py | 9 +++++++-- .../instrumentation/falcon/__init__.py | 14 ++++++++++---- .../instrumentation/flask/__init__.py | 13 +++++++++---- .../opentelemetry/instrumentation/wsgi/__init__.py | 7 +++++-- 5 files changed, 41 insertions(+), 15 deletions(-) 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 1ffd309324..e62535e53e 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py @@ -206,8 +206,12 @@ def client_response_hook(span: Span, scope: Scope, message: dict[str, Any]): Custom Metrics Attributes using Labeler *************************************** -The ASGI instrumentation reads from a Labeler utility that supports adding custom attributes -to the HTTP duration metrics recorded by the instrumentation. +The ASGI instrumentation reads from a labeler utility that supports adding custom +attributes to HTTP duration metrics at record time. The custom attributes are +stored only within the context of an instrumented request or operation. The +instrumentor does not overwrite base attributes that exist at the same keys as +any custom attributes. + .. code-block:: python @@ -220,18 +224,21 @@ def client_response_hook(span: Span, scope: Scope, message: dict[str, Any]): app = Quart(__name__) app.asgi_app = OpenTelemetryMiddleware(app.asgi_app) - @app.route("/user/") + @app.route("/users//") async def user_profile(user_id): # Get the labeler for the current request labeler = get_labeler() + # Add custom attributes to ASGI instrumentation metrics labeler.add("user_id", user_id) labeler.add("user_type", "registered") + # Or, add multiple attributes at once labeler.add_attributes({ "feature_flag": "new_ui", "experiment_group": "control" }) + return f"User profile for {user_id}" if __name__ == "__main__": diff --git a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py index 30d57523b0..981ef252c1 100644 --- a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py @@ -235,7 +235,12 @@ def response_hook(span, request, response): Custom Metrics Attributes using Labeler *************************************** -The Django instrumentation reads from a Labeler utility that supports adding custom attributes to the HTTP duration metrics recorded by the instrumentation. +The Django instrumentation reads from a labeler utility that supports adding custom +attributes to HTTP duration metrics at record time. The custom attributes are +stored only within the context of an instrumented request or operation. The +instrumentor does not overwrite base attributes that exist at the same keys as +any custom attributes. + .. code:: python @@ -245,7 +250,7 @@ def response_hook(span, request, response): DjangoInstrumentor().instrument() - # For urlpattern `/user//` mapped elsewhere + # Note: urlpattern `/users//` mapped elsewhere def my_user_view(request, user_id): # Get the labeler for the current request labeler = get_labeler() diff --git a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py index 05bd16d4d9..95c046cda3 100644 --- a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py @@ -182,35 +182,41 @@ def response_hook(span, req, resp): Custom Metrics Attributes using Labeler *************************************** -The Falcon instrumentation reads from a Labeler utility that supports adding custom attributes -to the HTTP duration metrics recorded by the instrumentation. +The Falcon instrumentation reads from a labeler utility that supports adding custom +attributes to HTTP duration metrics at record time. The custom attributes are +stored only within the context of an instrumented request or operation. The +instrumentor does not overwrite base attributes that exist at the same keys as +any custom attributes. .. code-block:: python import falcon + from opentelemetry.instrumentation._labeler import get_labeler from opentelemetry.instrumentation.falcon import FalconInstrumentor FalconInstrumentor().instrument() - app = falcon.App() class UserProfileResource: def on_get(self, req, resp, user_id): # Get the labeler for the current request labeler = get_labeler() + # Add custom attributes to Falcon instrumentation metrics labeler.add("user_id", user_id) labeler.add("user_type", "registered") + # Or, add multiple attributes at once labeler.add_attributes({ "feature_flag": "new_ui", "experiment_group": "control" }) + resp.text = f'User profile for {user_id}' - app.add_route('/user/{user_id}', UserProfileResource()) + app.add_route('/users/{user_id}/', UserProfileResource()) API --- diff --git a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py index 326990cbfa..fdbeb39be3 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py @@ -97,19 +97,24 @@ def hello(): Custom Metrics Attributes using Labeler *************************************** -The Flask instrumentation reads from a Labeler utility that supports adding custom attributes -to the HTTP request duration metrics recorded by the instrumentation. +The Flask instrumentation reads from a labeler utility that supports adding custom +attributes to HTTP duration metrics at record time. The custom attributes are +stored only within the context of an instrumented request or operation. The +instrumentor does not overwrite base attributes that exist at the same keys as +any custom attributes. + .. code-block:: python from flask import Flask - from opentelemetry.instrumentation.flask import FlaskInstrumentor + from opentelemetry.instrumentation._labeler import get_labeler + from opentelemetry.instrumentation.flask import FlaskInstrumentor app = Flask(__name__) FlaskInstrumentor().instrument_app(app) - @app.route("/user/") + @app.route("/users//") def user_profile(user_id): # Get the labeler for the current request labeler = get_labeler() diff --git a/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py index 39427b140b..0b22afcc2a 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py @@ -81,8 +81,11 @@ def GET(self): Custom Metrics Attributes using Labeler *************************************** -The WSGI instrumentation reads from a labeler utility that supports adding custom attributes -to HTTP duration metrics at record time. +The WSGI instrumentation reads from a labeler utility that supports adding custom +attributes to HTTP duration metrics at record time. The custom attributes are +stored only within the context of an instrumented request or operation. The +instrumentor does not overwrite base attributes that exist at the same keys as +any custom attributes. .. code-block:: python From 2c64576f13364739df98046889a89193327c940c Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 22 Sep 2025 12:21:26 -0700 Subject: [PATCH 27/42] Set Labeler Max at init --- .../_labeler/_internal/__init__.py | 85 ++++++++++++++----- .../tests/test_labeler.py | 2 +- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py index e41157b201..40a7f859c7 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py @@ -29,32 +29,76 @@ class Labeler: This feature is experimental and unstable. """ - def __init__(self): + def __init__( + self, max_custom_attrs: int = 20, max_attr_value_length: int = 100 + ): + """ + Initialize a new Labeler instance. + + Args: + max_custom_attrs: Maximum number of custom attributes to store. + When this limit is reached, new attributes will be ignored; + existing attributes can still be updated. + max_attr_value_length: Maximum length for string attribute values. + String values exceeding this length will be truncated. + """ self._lock = threading.Lock() self._attributes: Dict[str, Union[str, int, float, bool]] = {} + self._max_custom_attrs = max_custom_attrs + self._max_attr_value_length = max_attr_value_length def add(self, key: str, value: Union[str, int, float, bool]) -> None: """ - Add a single attribute to the labeler. + Add a single attribute to the labeler, subject to the labeler's limits: + - If max_custom_attrs limit is reached and this is a new key, the attribute is ignored + - String values exceeding max_attr_value_length are truncated Args: - key: The attribute key - value: The attribute value (must be a primitive type) + key: attribute key + value: attribute value, must be a primitive type: str, int, float, or bool """ with self._lock: + if ( + len(self._attributes) >= self._max_custom_attrs + and key not in self._attributes + ): + return + + if ( + isinstance(value, str) + and len(value) > self._max_attr_value_length + ): + value = value[: self._max_attr_value_length] + self._attributes[key] = value def add_attributes( self, attributes: Dict[str, Union[str, int, float, bool]] ) -> None: """ - Add multiple attributes to the labeler. + Add multiple attributes to the labeler, subject to the labeler's limits: + - If max_custom_attrs limit is reached and this is a new key, the attribute is ignored + - String values exceeding max_attr_value_length are truncated Args: - attributes: Dictionary of attributes to add + attributes: Dictionary of attributes to add. Values must be primitive types + (str, int, float, or bool) """ with self._lock: - self._attributes.update(attributes) + for key, value in attributes.items(): + if ( + len(self._attributes) >= self._max_custom_attrs + and key not in self._attributes + ): + break + + if ( + isinstance(value, str) + and len(value) > self._max_attr_value_length + ): + value = value[: self._max_attr_value_length] + + self._attributes[key] = value def get_attributes(self) -> Dict[str, Union[str, int, float, bool]]: """ @@ -123,21 +167,17 @@ def get_labeler_attributes() -> Dict[str, Union[str, int, float, bool]]: def enhance_metric_attributes( base_attributes: Dict[str, Any], include_custom: bool = True, - max_custom_attrs: int = 20, - max_attr_value_length: int = 100, ) -> Dict[str, Any]: """ Combines base_attributes with custom attributes from the current labeler, - returning a new dictionary of attributes. Custom attributes are skipped - if they would override base_attributes, or exceed max_custom_attrs number. - If custom attributes have string values exceeding the max_attr_value_length, - then they are truncated. + returning a new dictionary of attributes according to the labeler configuration: + - Attributes that would override base_attributes are skipped + - If max_custom_attrs limit is reached and this is a new key, the attribute is ignored + - String values exceeding max_attr_value_length are truncated Args: base_attributes: The base attributes for the metric include_custom: Whether to include custom labeler attributes - max_custom_attrs: Maximum number of custom attributes to include - max_attr_value_length: Maximum length for string attribute values Returns: Dictionary combining base and custom attributes. If no custom attributes, @@ -146,7 +186,11 @@ def enhance_metric_attributes( if not include_custom: return base_attributes.copy() - custom_attributes = get_labeler_attributes() + labeler = _labeler_context.get() + if labeler is None: + return base_attributes.copy() + + custom_attributes = labeler.get_attributes() if not custom_attributes: return base_attributes.copy() @@ -154,13 +198,16 @@ def enhance_metric_attributes( added_count = 0 for key, value in custom_attributes.items(): - if added_count >= max_custom_attrs: + if added_count >= labeler._max_custom_attrs: break if key in base_attributes: continue - if isinstance(value, str) and len(value) > max_attr_value_length: - value = value[:max_attr_value_length] + if ( + isinstance(value, str) + and len(value) > labeler._max_attr_value_length + ): + value = value[: labeler._max_attr_value_length] enhanced_attributes[key] = value added_count += 1 diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py index 66ea975c61..8a0f511f93 100644 --- a/opentelemetry-instrumentation/tests/test_labeler.py +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -77,7 +77,7 @@ def test_clear_attributes(self): self.assertEqual(len(labeler), 0) def test_thread_safety(self): - labeler = Labeler() + labeler = Labeler(max_custom_attrs=1000) num_threads = 10 num_operations = 100 From 0bfd192ed078561c71f719ec1d20dfe2d4cb6ba8 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 22 Sep 2025 13:31:58 -0700 Subject: [PATCH 28/42] Update and add test --- .../tests/test_labeler.py | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py index 8a0f511f93..1a3e0f2467 100644 --- a/opentelemetry-instrumentation/tests/test_labeler.py +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -77,7 +77,7 @@ def test_clear_attributes(self): self.assertEqual(len(labeler), 0) def test_thread_safety(self): - labeler = Labeler(max_custom_attrs=1000) + labeler = Labeler(max_custom_attrs=1100) # 11 * 100 num_threads = 10 num_operations = 100 @@ -87,6 +87,8 @@ def worker(thread_id): f"thread_{thread_id}_key_{i_operation}", f"value_{i_operation}", ) + # "shared" key that all 10 threads compete to write to + labeler.add("shared", thread_id) # Start multiple threads threads = [] @@ -99,10 +101,45 @@ def worker(thread_id): for thread in threads: thread.join() - # Check that all attributes were added attributes = labeler.get_attributes() - expected_count = num_threads * num_operations - self.assertEqual(len(attributes), expected_count) + # Should have all unique keys plus "shared" + expected_unique_keys = num_threads * num_operations + self.assertEqual(len(attributes), expected_unique_keys + 1) + # "shared" key should exist and have some valid thread_id + self.assertIn("shared", attributes) + self.assertIn(attributes["shared"], range(num_threads)) + + def test_thread_safety_atomic_increment(self): + """More non-atomic operations than test_thread_safety""" + labeler = Labeler(max_custom_attrs=100) + labeler.add("counter", 0) + num_threads = 100 + increments_per_thread = 50 + expected_final_value = num_threads * increments_per_thread + + def increment_worker(): + for _ in range(increments_per_thread): + # read-modify-write to increase contention + attrs = labeler.get_attributes() # Read + current = attrs["counter"] # Extract + new_value = current + 1 # Modify + labeler.add("counter", new_value) # Write + + threads = [] + for _ in range(num_threads): + thread = threading.Thread(target=increment_worker) + threads.append(thread) + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + final_value = labeler.get_attributes()["counter"] + self.assertEqual( + final_value, expected_final_value, + f"Expected {expected_final_value}, got {final_value}. " + f"Lost {expected_final_value - final_value} updates due to race conditions." + ) class TestLabelerContext(unittest.TestCase): From 01707e7328a189e88db33e4d2adb4b4665d9845a Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 22 Sep 2025 13:52:02 -0700 Subject: [PATCH 29/42] lint --- .../tests/test_labeler.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py index 1a3e0f2467..b7712a574c 100644 --- a/opentelemetry-instrumentation/tests/test_labeler.py +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -116,15 +116,15 @@ def test_thread_safety_atomic_increment(self): num_threads = 100 increments_per_thread = 50 expected_final_value = num_threads * increments_per_thread - + def increment_worker(): for _ in range(increments_per_thread): # read-modify-write to increase contention attrs = labeler.get_attributes() # Read - current = attrs["counter"] # Extract - new_value = current + 1 # Modify - labeler.add("counter", new_value) # Write - + current = attrs["counter"] # Extract + new_value = current + 1 # Modify + labeler.add("counter", new_value) # Write + threads = [] for _ in range(num_threads): thread = threading.Thread(target=increment_worker) @@ -133,12 +133,13 @@ def increment_worker(): thread.start() for thread in threads: thread.join() - + final_value = labeler.get_attributes()["counter"] self.assertEqual( - final_value, expected_final_value, + final_value, + expected_final_value, f"Expected {expected_final_value}, got {final_value}. " - f"Lost {expected_final_value - final_value} updates due to race conditions." + f"Lost {expected_final_value - final_value} updates due to race conditions.", ) From c86a538ce80cf0832f31ff404adc4b60ba739cc1 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 22 Sep 2025 13:54:27 -0700 Subject: [PATCH 30/42] get_attrs MappingProxyType --- .../instrumentation/_labeler/_internal/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py index 40a7f859c7..cefe104da7 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py @@ -14,6 +14,7 @@ import contextvars import threading +from types import MappingProxyType from typing import Any, Dict, Optional, Union # Context variable to store the current labeler @@ -105,7 +106,7 @@ def get_attributes(self) -> Dict[str, Union[str, int, float, bool]]: Returns a copy of all attributes added to the labeler. """ with self._lock: - return self._attributes.copy() + return MappingProxyType(self._attributes) def clear(self) -> None: with self._lock: @@ -160,7 +161,7 @@ def get_labeler_attributes() -> Dict[str, Union[str, int, float, bool]]: """ labeler = _labeler_context.get() if labeler is None: - return {} + return MappingProxyType({}) return labeler.get_attributes() From e10dc80988d7f366aff02dab915dd034c250efac Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 22 Sep 2025 14:09:04 -0700 Subject: [PATCH 31/42] Skip and warn if invalid attr for Labeler --- .../_labeler/_internal/__init__.py | 19 +++ .../tests/test_labeler.py | 118 ++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py index cefe104da7..4d13301cc1 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py @@ -13,6 +13,7 @@ # limitations under the License. import contextvars +import logging import threading from types import MappingProxyType from typing import Any, Dict, Optional, Union @@ -22,6 +23,8 @@ contextvars.ContextVar("otel_labeler", default=None) ) +_logger = logging.getLogger(__name__) + class Labeler: """ @@ -58,6 +61,14 @@ def add(self, key: str, value: Union[str, int, float, bool]) -> None: key: attribute key value: attribute value, must be a primitive type: str, int, float, or bool """ + if not isinstance(value, (str, int, float, bool)): + _logger.warning( + "Skipping attribute '%s': value must be str, int, float, or bool, got %s", + key, + type(value).__name__, + ) + return + with self._lock: if ( len(self._attributes) >= self._max_custom_attrs @@ -87,6 +98,14 @@ def add_attributes( """ with self._lock: for key, value in attributes.items(): + if not isinstance(value, (str, int, float, bool)): + _logger.warning( + "Skipping attribute '%s': value must be str, int, float, or bool, got %s", + key, + type(value).__name__, + ) + continue + if ( len(self._attributes) >= self._max_custom_attrs and key not in self._attributes diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py index b7712a574c..acb3218b28 100644 --- a/opentelemetry-instrumentation/tests/test_labeler.py +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -16,6 +16,7 @@ import contextvars import threading import unittest +from unittest.mock import patch from opentelemetry.instrumentation._labeler import ( Labeler, @@ -76,6 +77,123 @@ def test_clear_attributes(self): self.assertEqual(labeler.get_attributes(), {}) self.assertEqual(len(labeler), 0) + def test_add_valid_types(self): + labeler = Labeler() + labeler.add("str_key", "string_value") + labeler.add("int_key", 42) + labeler.add("float_key", 3.14) + labeler.add("bool_true_key", True) + labeler.add("bool_false_key", False) + + attributes = labeler.get_attributes() + expected = { + "str_key": "string_value", + "int_key": 42, + "float_key": 3.14, + "bool_true_key": True, + "bool_false_key": False, + } + self.assertEqual(attributes, expected) + self.assertEqual(len(labeler), 5) + + def test_add_invalid_types_logs_warning_and_skips(self): + labeler = Labeler() + + with patch( + "opentelemetry.instrumentation._labeler._internal._logger.warning" + ) as mock_warning: + labeler.add("valid", "value") + + labeler.add("dict_key", {"nested": "dict"}) + labeler.add("list_key", [1, 2, 3]) + labeler.add("none_key", None) + labeler.add("tuple_key", (1, 2)) + labeler.add("set_key", {1, 2, 3}) + + labeler.add("another_valid", 123) + + self.assertEqual(mock_warning.call_count, 5) + warning_calls = [call[0] for call in mock_warning.call_args_list] + self.assertIn("dict_key", str(warning_calls[0])) + self.assertIn("dict", str(warning_calls[0])) + self.assertIn("list_key", str(warning_calls[1])) + self.assertIn("list", str(warning_calls[1])) + self.assertIn("none_key", str(warning_calls[2])) + self.assertIn("NoneType", str(warning_calls[2])) + + attributes = labeler.get_attributes() + expected = {"valid": "value", "another_valid": 123} + self.assertEqual(attributes, expected) + self.assertEqual(len(labeler), 2) + + def test_add_attributes_valid_types(self): + labeler = Labeler() + attrs = { + "str_key": "string_value", + "int_key": 42, + "float_key": 3.14, + "bool_true_key": True, + "bool_false_key": False, + } + labeler.add_attributes(attrs) + attributes = labeler.get_attributes() + self.assertEqual(attributes, attrs) + self.assertEqual(len(labeler), 5) + + def test_add_attributes_invalid_types_logs_and_skips(self): + labeler = Labeler() + + with patch( + "opentelemetry.instrumentation._labeler._internal._logger.warning" + ) as mock_warning: + mixed_attrs = { + "valid_str": "value", + "invalid_dict": {"nested": "dict"}, + "valid_int": 42, + "invalid_list": [1, 2, 3], + "valid_bool": True, + "invalid_none": None, + } + labeler.add_attributes(mixed_attrs) + + self.assertEqual(mock_warning.call_count, 3) + warning_calls = [str(call) for call in mock_warning.call_args_list] + self.assertTrue(any("invalid_dict" in call for call in warning_calls)) + self.assertTrue(any("invalid_list" in call for call in warning_calls)) + self.assertTrue(any("invalid_none" in call for call in warning_calls)) + attributes = labeler.get_attributes() + expected = { + "valid_str": "value", + "valid_int": 42, + "valid_bool": True, + } + self.assertEqual(attributes, expected) + self.assertEqual(len(labeler), 3) + + def test_add_attributes_all_invalid_types(self): + """Test add_attributes when all types are invalid""" + labeler = Labeler() + + with patch( + "opentelemetry.instrumentation._labeler._internal._logger.warning" + ) as mock_warning: + invalid_attrs = { + "dict_key": {"nested": "dict"}, + "list_key": [1, 2, 3], + "none_key": None, + "custom_obj": object(), + } + + labeler.add_attributes(invalid_attrs) + + # Should have logged warnings for all 4 invalid attributes + self.assertEqual(mock_warning.call_count, 4) + + # No attributes should be stored + attributes = labeler.get_attributes() + self.assertEqual(attributes, {}) + self.assertEqual(len(labeler), 0) + def test_thread_safety(self): labeler = Labeler(max_custom_attrs=1100) # 11 * 100 num_threads = 10 From 068d0c48069c512248c221b86860bc9388e7388c Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 22 Sep 2025 14:21:52 -0700 Subject: [PATCH 32/42] Rename enhance to enrich --- .../opentelemetry/instrumentation/asgi/__init__.py | 6 +++--- .../django/middleware/otel_middleware.py | 6 +++--- .../instrumentation/falcon/__init__.py | 6 +++--- .../instrumentation/flask/__init__.py | 6 +++--- .../opentelemetry/instrumentation/wsgi/__init__.py | 6 +++--- .../instrumentation/_labeler/__init__.py | 6 +++--- .../instrumentation/_labeler/_internal/__init__.py | 14 +++++++------- 7 files changed, 25 insertions(+), 25 deletions(-) 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 b08a091f01..e5a5c9ea4f 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py @@ -261,7 +261,7 @@ async def user_profile(user_id): from opentelemetry import context, trace from opentelemetry.instrumentation._labeler import ( - enhance_metric_attributes, + enrich_metric_attributes, get_labeler, ) from opentelemetry.instrumentation._semconv import ( @@ -857,7 +857,7 @@ async def __call__( attributes, _StabilityMode.DEFAULT ) # Enhance attributes with any custom labeler attributes - duration_attrs_old = enhance_metric_attributes( + duration_attrs_old = enrich_metric_attributes( duration_attrs_old ) if target: @@ -866,7 +866,7 @@ async def __call__( attributes, _StabilityMode.HTTP ) # Enhance attributes with any custom labeler attributes - duration_attrs_new = enhance_metric_attributes( + duration_attrs_new = enrich_metric_attributes( duration_attrs_new ) if self.duration_histogram_old: diff --git a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py index 6c812d2ec6..eadb4cb9e1 100644 --- a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py @@ -22,7 +22,7 @@ from django.http import HttpRequest, HttpResponse from opentelemetry.context import detach -from opentelemetry.instrumentation._labeler import enhance_metric_attributes +from opentelemetry.instrumentation._labeler import enrich_metric_attributes from opentelemetry.instrumentation._semconv import ( _filter_semconv_active_request_count_attr, _filter_semconv_duration_attrs, @@ -438,7 +438,7 @@ def process_response(self, request, response): if target: duration_attrs_old[SpanAttributes.HTTP_TARGET] = target # Enhance attributes with any custom labeler attributes - duration_attrs_old = enhance_metric_attributes( + duration_attrs_old = enrich_metric_attributes( duration_attrs_old ) self._duration_histogram_old.record( @@ -449,7 +449,7 @@ def process_response(self, request, response): duration_attrs, _StabilityMode.HTTP ) # Enhance attributes with any custom labeler attributes - duration_attrs_new = enhance_metric_attributes( + duration_attrs_new = enrich_metric_attributes( duration_attrs_new ) self._duration_histogram_new.record( diff --git a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py index 95c046cda3..c243b798be 100644 --- a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py @@ -233,7 +233,7 @@ def on_get(self, req, resp, user_id): import opentelemetry.instrumentation.wsgi as otel_wsgi from opentelemetry import context, trace -from opentelemetry.instrumentation._labeler import enhance_metric_attributes +from opentelemetry.instrumentation._labeler import enrich_metric_attributes from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, _get_schema_url, @@ -460,7 +460,7 @@ def _start_response(status, response_headers, *args, **kwargs): attributes, _StabilityMode.DEFAULT ) # Enhance attributes with any custom labeler attributes - duration_attrs = enhance_metric_attributes(duration_attrs) + duration_attrs = enrich_metric_attributes(duration_attrs) self.duration_histogram_old.record( max(round(duration_s * 1000), 0), duration_attrs ) @@ -469,7 +469,7 @@ def _start_response(status, response_headers, *args, **kwargs): attributes, _StabilityMode.HTTP ) # Enhance attributes with any custom labeler attributes - duration_attrs = enhance_metric_attributes(duration_attrs) + duration_attrs = enrich_metric_attributes(duration_attrs) self.duration_histogram_new.record( max(duration_s, 0), duration_attrs ) diff --git a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py index b8b3284bc6..cd5378ac74 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py @@ -290,7 +290,7 @@ def response_hook(span: Span, status: str, response_headers: List): import opentelemetry.instrumentation.wsgi as otel_wsgi from opentelemetry import context, trace -from opentelemetry.instrumentation._labeler import enhance_metric_attributes +from opentelemetry.instrumentation._labeler import enrich_metric_attributes from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, _get_schema_url, @@ -443,7 +443,7 @@ def _start_response(status, response_headers, *args, **kwargs): duration_attrs_old[HTTP_TARGET] = str(request_route) # Enhance attributes with any custom labeler attributes - duration_attrs_old = enhance_metric_attributes(duration_attrs_old) + duration_attrs_old = enrich_metric_attributes(duration_attrs_old) duration_histogram_old.record( max(round(duration_s * 1000), 0), duration_attrs_old @@ -457,7 +457,7 @@ def _start_response(status, response_headers, *args, **kwargs): duration_attrs_new[HTTP_ROUTE] = str(request_route) # Enhance attributes with any custom labeler attributes - duration_attrs_new = enhance_metric_attributes(duration_attrs_new) + duration_attrs_new = enrich_metric_attributes(duration_attrs_new) duration_histogram_new.record( max(duration_s, 0), duration_attrs_new diff --git a/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py index 0b22afcc2a..2dab70e218 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py @@ -272,7 +272,7 @@ def response_hook(span: Span, environ: WSGIEnvironment, status: str, response_he from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, TypeVar, cast from opentelemetry import context, trace -from opentelemetry.instrumentation._labeler import enhance_metric_attributes +from opentelemetry.instrumentation._labeler import enrich_metric_attributes from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, _filter_semconv_active_request_count_attr, @@ -763,7 +763,7 @@ def __call__( req_attrs, _StabilityMode.DEFAULT ) # Enhance attributes with any custom labeler attributes - duration_attrs_old = enhance_metric_attributes( + duration_attrs_old = enrich_metric_attributes( duration_attrs_old ) self.duration_histogram_old.record( @@ -774,7 +774,7 @@ def __call__( req_attrs, _StabilityMode.HTTP ) # Enhance attributes with any custom labeler attributes - duration_attrs_new = enhance_metric_attributes( + duration_attrs_new = enrich_metric_attributes( duration_attrs_new ) self.duration_histogram_new.record( diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py index 4f2a59fea1..baaa37ca78 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py @@ -63,13 +63,13 @@ def user_profile(user_id): The labeler also works with auto-instrumentation. -Custom attributes are merged by any instrumentors that use ``enhance_metric_attributes`` before their calls to report individual metrics recording, such as ``Histogram.record``. ``enchance_metrics_attributes`` does not overwrite base attributes that exist at the same keys. +Custom attributes are merged by any instrumentors that use ``enrich_metric_attributes`` before their calls to report individual metrics recording, such as ``Histogram.record``. ``enchance_metrics_attributes`` does not overwrite base attributes that exist at the same keys. """ from opentelemetry.instrumentation._labeler._internal import ( Labeler, clear_labeler, - enhance_metric_attributes, + enrich_metric_attributes, get_labeler, get_labeler_attributes, set_labeler, @@ -81,5 +81,5 @@ def user_profile(user_id): "set_labeler", "clear_labeler", "get_labeler_attributes", - "enhance_metric_attributes", + "enrich_metric_attributes", ] diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py index 4d13301cc1..9ea3050562 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py @@ -184,9 +184,9 @@ def get_labeler_attributes() -> Dict[str, Union[str, int, float, bool]]: return labeler.get_attributes() -def enhance_metric_attributes( +def enrich_metric_attributes( base_attributes: Dict[str, Any], - include_custom: bool = True, + enrich_enabled: bool = True, ) -> Dict[str, Any]: """ Combines base_attributes with custom attributes from the current labeler, @@ -197,13 +197,13 @@ def enhance_metric_attributes( Args: base_attributes: The base attributes for the metric - include_custom: Whether to include custom labeler attributes + enrich_enabled: Whether to include custom labeler attributes Returns: Dictionary combining base and custom attributes. If no custom attributes, returns a copy of the original base attributes. """ - if not include_custom: + if not enrich_enabled: return base_attributes.copy() labeler = _labeler_context.get() @@ -214,7 +214,7 @@ def enhance_metric_attributes( if not custom_attributes: return base_attributes.copy() - enhanced_attributes = base_attributes.copy() + enriched_attributes = base_attributes.copy() added_count = 0 for key, value in custom_attributes.items(): @@ -229,7 +229,7 @@ def enhance_metric_attributes( ): value = value[: labeler._max_attr_value_length] - enhanced_attributes[key] = value + enriched_attributes[key] = value added_count += 1 - return enhanced_attributes + return enriched_attributes From 4d7c1c3d5abdbc9c081d35507837cb537a9dad27 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 22 Sep 2025 14:25:26 -0700 Subject: [PATCH 33/42] Changelog --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d066e607..7a2ec3ab04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`, `opentelemetry-instrumentation-wsgi`, `opentelemetry-instrumentation-django`, `opentelemetry-instrumentation-falcon`, `opentelemetry-instrumentation-asgi`: Add Labeler utility. Add FalconInstrumentor, FlaskInstrumentor, DjangoInstrumentor, WsgiInstrumentor, AsgiInstrumentor support of custom attributes merging for HTTP duration metrics. + ([#3689](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3689)) + + ## Version 1.37.0/0.58b0 (2025-09-11) ### Fixed @@ -54,8 +60,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3734](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3734)) - `opentelemetry-instrumentation`: botocore: upgrade moto package from 5.0.9 to 5.1.11 ([#3736](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3736)) -- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`, `opentelemetry-instrumentation-wsgi`, `opentelemetry-instrumentation-django`, `opentelemetry-instrumentation-falcon`, `opentelemetry-instrumentation-asgi`: Add Labeler utility. Add FalconInstrumentor, FlaskInstrumentor, DjangoInstrumentor, WsgiInstrumentor, AsgiInstrumentor support of custom attributes merging for HTTP duration metrics. - ([#3689](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3689)) ## Version 1.36.0/0.57b0 (2025-07-29) From bfcb9f11b8ce9521ba987b94b5a9ea7c3d9ec3e5 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Wed, 12 Nov 2025 09:37:15 -0800 Subject: [PATCH 34/42] lint --- .../src/opentelemetry/instrumentation/flask/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py index 3cba27234e..0a9830c4a6 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py @@ -457,7 +457,9 @@ def _start_response(status, response_headers, *args, **kwargs): duration_attrs_old[HTTP_TARGET] = str(request_route) # Enhance attributes with any custom labeler attributes - duration_attrs_old = enrich_metric_attributes(duration_attrs_old) + duration_attrs_old = enrich_metric_attributes( + duration_attrs_old + ) duration_histogram_old.record( max(round(duration_s * 1000), 0), duration_attrs_old @@ -471,7 +473,9 @@ def _start_response(status, response_headers, *args, **kwargs): duration_attrs_new[HTTP_ROUTE] = str(request_route) # Enhance attributes with any custom labeler attributes - duration_attrs_new = enrich_metric_attributes(duration_attrs_new) + duration_attrs_new = enrich_metric_attributes( + duration_attrs_new + ) duration_histogram_new.record( max(duration_s, 0), duration_attrs_new From 98f11b792c5429ec89dcfc96c957ee0882e9f920 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Thu, 15 Jan 2026 13:46:23 -0800 Subject: [PATCH 35/42] lint --- .../tests/test_wsgi_middleware.py | 1 + 1 file changed, 1 insertion(+) diff --git a/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py b/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py index 1ef74ad049..6219882ef1 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py @@ -239,6 +239,7 @@ def wsgi_with_repeat_custom_response_headers(environ, start_response): } +# pylint: disable=too-many-public-methods class TestWsgiApplication(WsgiTestBase): def setUp(self): super().setUp() From 286e12b741a2d147ba4fdd2c904049ccbc71eda3 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Thu, 15 Jan 2026 14:17:11 -0800 Subject: [PATCH 36/42] Stop duplicate recording --- .../django/middleware/otel_middleware.py | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py index 07b1785b9b..0d4d9c9ed3 100644 --- a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py @@ -21,6 +21,7 @@ from django import VERSION as django_version from django.http import HttpRequest, HttpResponse +from opentelemetry import trace from opentelemetry.context import detach from opentelemetry.instrumentation._labeler import enrich_metric_attributes from opentelemetry.instrumentation._semconv import ( @@ -406,30 +407,6 @@ def process_response(self, request, response): except Exception: # pylint: disable=broad-exception-caught _logger.exception("Exception raised by response_hook") - if request_start_time is not None: - duration_s = default_timer() - request_start_time - if self._duration_histogram_old: - duration_attrs_old = _parse_duration_attrs( - duration_attrs, _StabilityMode.DEFAULT - ) - # http.target to be included in old semantic conventions - target = duration_attrs.get(HTTP_TARGET) - if target: - duration_attrs_old[HTTP_TARGET] = target - self._duration_histogram_old.record( - max(round(duration_s * 1000), 0), - duration_attrs_old, - ) - if self._duration_histogram_new: - duration_attrs_new = _parse_duration_attrs( - duration_attrs, _StabilityMode.HTTP - ) - self._duration_histogram_new.record( - max(duration_s, 0), - duration_attrs_new, - ) - self._active_request_counter.add(-1, active_requests_count_attrs) - if activation and span: if exception: activation.__exit__( @@ -442,6 +419,7 @@ def process_response(self, request, response): if request_start_time is not None: duration_s = default_timer() - request_start_time + span_ctx = trace.set_span_in_context(span) if self._duration_histogram_old: duration_attrs_old = _parse_duration_attrs( duration_attrs, _StabilityMode.DEFAULT @@ -455,7 +433,9 @@ def process_response(self, request, response): duration_attrs_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: duration_attrs_new = _parse_duration_attrs( @@ -466,7 +446,9 @@ def process_response(self, request, response): duration_attrs_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_request_counter.add(-1, active_requests_count_attrs) if request.META.get(self._environ_token, None) is not None: From 00708ca22a0c7944c4a4e4d31c29216ce203d29c Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Thu, 15 Jan 2026 15:04:21 -0800 Subject: [PATCH 37/42] Rm test_thread_safety_atomic_increment because it tested counter not labeler --- .../tests/test_labeler.py | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py index acb3218b28..29cc2b9f76 100644 --- a/opentelemetry-instrumentation/tests/test_labeler.py +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -227,39 +227,6 @@ def worker(thread_id): self.assertIn("shared", attributes) self.assertIn(attributes["shared"], range(num_threads)) - def test_thread_safety_atomic_increment(self): - """More non-atomic operations than test_thread_safety""" - labeler = Labeler(max_custom_attrs=100) - labeler.add("counter", 0) - num_threads = 100 - increments_per_thread = 50 - expected_final_value = num_threads * increments_per_thread - - def increment_worker(): - for _ in range(increments_per_thread): - # read-modify-write to increase contention - attrs = labeler.get_attributes() # Read - current = attrs["counter"] # Extract - new_value = current + 1 # Modify - labeler.add("counter", new_value) # Write - - threads = [] - for _ in range(num_threads): - thread = threading.Thread(target=increment_worker) - threads.append(thread) - for thread in threads: - thread.start() - for thread in threads: - thread.join() - - final_value = labeler.get_attributes()["counter"] - self.assertEqual( - final_value, - expected_final_value, - f"Expected {expected_final_value}, got {final_value}. " - f"Lost {expected_final_value - final_value} updates due to race conditions.", - ) - class TestLabelerContext(unittest.TestCase): def setUp(self): From bac83e26dffacd132be9e20a701134b387718de4 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 23 Feb 2026 16:16:19 -0800 Subject: [PATCH 38/42] Add Labeler to pyright; adjust return types --- .../instrumentation/_labeler/__init__.py | 2 +- .../_labeler/_internal/__init__.py | 20 ++++++++++--------- pyproject.toml | 1 + 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py index baaa37ca78..3667729c34 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py @@ -63,7 +63,7 @@ def user_profile(user_id): The labeler also works with auto-instrumentation. -Custom attributes are merged by any instrumentors that use ``enrich_metric_attributes`` before their calls to report individual metrics recording, such as ``Histogram.record``. ``enchance_metrics_attributes`` does not overwrite base attributes that exist at the same keys. +Custom attributes are merged by any instrumentors that use ``enrich_metric_attributes`` before their calls to report individual metrics recording, such as ``Histogram.record``. ``enhance_metrics_attributes`` does not overwrite base attributes that exist at the same keys. """ from opentelemetry.instrumentation._labeler._internal import ( diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py index 9ea3050562..f00978838c 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py @@ -16,7 +16,9 @@ import logging import threading from types import MappingProxyType -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Mapping, Optional, Union + +from opentelemetry.util.types import AttributeValue # Context variable to store the current labeler _labeler_context: contextvars.ContextVar[Optional["Labeler"]] = ( @@ -51,7 +53,7 @@ def __init__( self._max_custom_attrs = max_custom_attrs self._max_attr_value_length = max_attr_value_length - def add(self, key: str, value: Union[str, int, float, bool]) -> None: + def add(self, key: str, value: Any) -> None: """ Add a single attribute to the labeler, subject to the labeler's limits: - If max_custom_attrs limit is reached and this is a new key, the attribute is ignored @@ -84,9 +86,7 @@ def add(self, key: str, value: Union[str, int, float, bool]) -> None: self._attributes[key] = value - def add_attributes( - self, attributes: Dict[str, Union[str, int, float, bool]] - ) -> None: + def add_attributes(self, attributes: Dict[str, Any]) -> None: """ Add multiple attributes to the labeler, subject to the labeler's limits: - If max_custom_attrs limit is reached and this is a new key, the attribute is ignored @@ -120,7 +120,7 @@ def add_attributes( self._attributes[key] = value - def get_attributes(self) -> Dict[str, Union[str, int, float, bool]]: + def get_attributes(self) -> Mapping[str, Union[str, int, float, bool]]: """ Returns a copy of all attributes added to the labeler. """ @@ -171,7 +171,7 @@ def clear_labeler() -> None: _labeler_context.set(None) -def get_labeler_attributes() -> Dict[str, Union[str, int, float, bool]]: +def get_labeler_attributes() -> Mapping[str, Union[str, int, float, bool]]: """ Get attributes from the current labeler, if any. @@ -180,14 +180,16 @@ def get_labeler_attributes() -> Dict[str, Union[str, int, float, bool]]: """ labeler = _labeler_context.get() if labeler is None: - return MappingProxyType({}) + return MappingProxyType( + {} # type: Dict[str, Union[str, int, float, bool]] + ) return labeler.get_attributes() def enrich_metric_attributes( base_attributes: Dict[str, Any], enrich_enabled: bool = True, -) -> Dict[str, Any]: +) -> Dict[str, AttributeValue]: """ Combines base_attributes with custom attributes from the current labeler, returning a new dictionary of attributes according to the labeler configuration: diff --git a/pyproject.toml b/pyproject.toml index f73be7cf6a..f95d34d654 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -208,6 +208,7 @@ include = [ "util/opentelemetry-util-genai", "exporter/opentelemetry-exporter-credential-provider-gcp", "instrumentation/opentelemetry-instrumentation-aiohttp-client", + "opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler", ] # We should also add type hints to the test suite - It helps on finding bugs. # We are excluding for now because it's easier, and more important to add to the instrumentation packages. From 562626495d31aff726a886f52c7fe43fa0964149 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 23 Feb 2026 16:31:08 -0800 Subject: [PATCH 39/42] Test fixes for span.started metrics --- .../tests/test_asgi_middleware.py | 18 +- .../tests/test_wsgi_middleware.py | 156 ++++++++---------- 2 files changed, 76 insertions(+), 98 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py index df5188ac35..9ae8419baf 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py @@ -1564,11 +1564,9 @@ async def test_asgi_metrics_custom_attributes(self): for resource_metric in metrics_list.resource_metrics: self.assertTrue(len(resource_metric.scope_metrics) != 0) for scope_metric in resource_metric.scope_metrics: + if scope_metric.scope.name != SCOPE: + continue self.assertTrue(len(scope_metric.metrics) != 0) - self.assertEqual( - scope_metric.scope.name, - "opentelemetry.instrumentation.asgi", - ) for metric in scope_metric.metrics: self.assertIn(metric.name, _expected_metric_names_old) data_points = list(metric.data.data_points) @@ -1649,11 +1647,9 @@ async def test_asgi_metrics_new_semconv_custom_attributes(self): for resource_metric in metrics_list.resource_metrics: self.assertTrue(len(resource_metric.scope_metrics) != 0) for scope_metric in resource_metric.scope_metrics: + if scope_metric.scope.name != SCOPE: + continue self.assertTrue(len(scope_metric.metrics) != 0) - self.assertEqual( - scope_metric.scope.name, - "opentelemetry.instrumentation.asgi", - ) for metric in scope_metric.metrics: self.assertIn(metric.name, _expected_metric_names_new) data_points = list(metric.data.data_points) @@ -1732,11 +1728,9 @@ async def test_asgi_metrics_both_semconv_custom_attributes(self): for resource_metric in metrics_list.resource_metrics: self.assertTrue(len(resource_metric.scope_metrics) != 0) for scope_metric in resource_metric.scope_metrics: + if scope_metric.scope.name != SCOPE: + continue self.assertTrue(len(scope_metric.metrics) != 0) - self.assertEqual( - scope_metric.scope.name, - "opentelemetry.instrumentation.asgi", - ) for metric in scope_metric.metrics: self.assertIn(metric.name, _expected_metric_names_both) data_points = list(metric.data.data_points) diff --git a/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py b/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py index 67bc1f242d..071e758ee4 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py @@ -495,32 +495,28 @@ def test_wsgi_metrics_custom_attributes(self): self.assertRaises(ValueError, app, self.environ, self.start_response) self.assertRaises(ValueError, app, self.environ, self.start_response) self.assertRaises(ValueError, app, self.environ, self.start_response) - metrics_list = self.memory_metrics_reader.get_metrics_data() number_data_point_seen = False histogram_data_point_seen = False - self.assertTrue(len(metrics_list.resource_metrics) != 0) - for resource_metric in metrics_list.resource_metrics: - self.assertTrue(len(resource_metric.scope_metrics) != 0) - for scope_metric in resource_metric.scope_metrics: - self.assertTrue(len(scope_metric.metrics) != 0) - for metric in scope_metric.metrics: - self.assertIn(metric.name, _expected_metric_names_old) - data_points = list(metric.data.data_points) - self.assertEqual(len(data_points), 1) - for point in data_points: - if isinstance(point, HistogramDataPoint): - self.assertEqual(point.count, 3) - histogram_data_point_seen = True - if isinstance(point, NumberDataPoint): - number_data_point_seen = True - for attr in point.attributes: - self.assertIn( - attr, - _recommended_metrics_attrs_old_with_custom[ - metric.name - ], - ) + metrics = self.get_sorted_metrics(SCOPE) + self.assertTrue(len(metrics) > 0) + for metric in metrics: + self.assertIn(metric.name, _expected_metric_names_old) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_old_with_custom[ + metric.name + ], + ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) def test_wsgi_metrics_exemplars_expected_old_semconv(self): # type: ignore[func-returns-value] @@ -598,37 +594,33 @@ def test_wsgi_metrics_new_semconv_custom_attributes(self): self.assertRaises(ValueError, app, self.environ, self.start_response) self.assertRaises(ValueError, app, self.environ, self.start_response) self.assertRaises(ValueError, app, self.environ, self.start_response) - metrics_list = self.memory_metrics_reader.get_metrics_data() number_data_point_seen = False histogram_data_point_seen = False - self.assertTrue(len(metrics_list.resource_metrics) != 0) - for resource_metric in metrics_list.resource_metrics: - self.assertTrue(len(resource_metric.scope_metrics) != 0) - for scope_metric in resource_metric.scope_metrics: - self.assertTrue(len(scope_metric.metrics) != 0) - for metric in scope_metric.metrics: - self.assertIn(metric.name, _expected_metric_names_new) - data_points = list(metric.data.data_points) - self.assertEqual(len(data_points), 1) - for point in data_points: - if isinstance(point, HistogramDataPoint): - self.assertEqual(point.count, 3) - if metric.name == "http.server.request.duration": - self.assertEqual( - point.explicit_bounds, - HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, - ) - histogram_data_point_seen = True - if isinstance(point, NumberDataPoint): - number_data_point_seen = True - for attr in point.attributes: - self.assertIn( - attr, - _recommended_metrics_attrs_new_with_custom[ - metric.name - ], - ) + metrics = self.get_sorted_metrics(SCOPE) + self.assertTrue(len(metrics) > 0) + for metric in metrics: + self.assertIn(metric.name, _expected_metric_names_new) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + if metric.name == "http.server.request.duration": + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_new_with_custom[ + metric.name + ], + ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) def test_wsgi_metrics_both_semconv(self): @@ -671,46 +663,38 @@ def test_wsgi_metrics_both_semconv_custom_attributes(self): # pylint: disable=too-many-nested-blocks app = otel_wsgi.OpenTelemetryMiddleware(error_wsgi_unhandled) self.assertRaises(ValueError, app, self.environ, self.start_response) - metrics_list = self.memory_metrics_reader.get_metrics_data() number_data_point_seen = False histogram_data_point_seen = False - self.assertTrue(len(metrics_list.resource_metrics) != 0) - for resource_metric in metrics_list.resource_metrics: - self.assertTrue(len(resource_metric.scope_metrics) != 0) - for scope_metric in resource_metric.scope_metrics: - self.assertTrue(len(scope_metric.metrics) != 0) - for metric in scope_metric.metrics: - if metric.unit == "ms": - self.assertEqual(metric.name, "http.server.duration") - elif metric.unit == "s": - self.assertEqual( - metric.name, "http.server.request.duration" - ) - else: + metrics = self.get_sorted_metrics(SCOPE) + self.assertTrue(len(metrics) > 0) + for metric in metrics: + if metric.unit == "ms": + self.assertEqual(metric.name, "http.server.duration") + elif metric.unit == "s": + self.assertEqual(metric.name, "http.server.request.duration") + else: + self.assertEqual(metric.name, "http.server.active_requests") + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 1) + if metric.name == "http.server.request.duration": self.assertEqual( - metric.name, "http.server.active_requests" + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, ) - data_points = list(metric.data.data_points) - self.assertEqual(len(data_points), 1) - for point in data_points: - if isinstance(point, HistogramDataPoint): - self.assertEqual(point.count, 1) - if metric.name == "http.server.request.duration": - self.assertEqual( - point.explicit_bounds, - HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, - ) - histogram_data_point_seen = True - if isinstance(point, NumberDataPoint): - number_data_point_seen = True - for attr in point.attributes: - self.assertIn( - attr, - _recommended_metrics_attrs_both_with_custom[ - metric.name - ], - ) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_both_with_custom[ + metric.name + ], + ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) def test_nonstandard_http_method(self): From 675e94a27c55fa13cfe2baca1baad54c4437a960 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 23 Feb 2026 16:45:21 -0800 Subject: [PATCH 40/42] lint --- .../instrumentation/_labeler/_internal/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py index f00978838c..f15d81a717 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py @@ -180,9 +180,8 @@ def get_labeler_attributes() -> Mapping[str, Union[str, int, float, bool]]: """ labeler = _labeler_context.get() if labeler is None: - return MappingProxyType( - {} # type: Dict[str, Union[str, int, float, bool]] - ) + empty_attributes: Dict[str, Union[str, int, float, bool]] = {} + return MappingProxyType(empty_attributes) return labeler.get_attributes() From 1e3569701545277fc082255785a40fe9a45751c5 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 23 Feb 2026 17:11:33 -0800 Subject: [PATCH 41/42] More test fixes for span.started metrics --- .../tests/test_middleware.py | 190 ++++++++---------- .../tests/test_falcon.py | 92 ++++----- .../tests/test_programmatic.py | 100 ++++----- 3 files changed, 171 insertions(+), 211 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py index dd3994b822..33cbebfd94 100644 --- a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py @@ -801,37 +801,31 @@ def test_wsgi_metrics_custom_attributes(self): response = Client().get("/span_name_custom_attrs/1234/") self.assertEqual(response.status_code, 200) duration = max(round((default_timer() - start) * 1000), 0) - metrics_list = self.memory_metrics_reader.get_metrics_data() + metrics = self.get_sorted_metrics(SCOPE) number_data_point_seen = False histrogram_data_point_seen = False - self.assertTrue(len(metrics_list.resource_metrics) != 0) - for resource_metric in metrics_list.resource_metrics: - self.assertTrue(len(resource_metric.scope_metrics) != 0) - for scope_metric in resource_metric.scope_metrics: - self.assertTrue(len(scope_metric.metrics) != 0) - for metric in scope_metric.metrics: - self.assertIn(metric.name, _expected_metric_names) - data_points = list(metric.data.data_points) - self.assertEqual(len(data_points), 1) - for point in data_points: - if isinstance(point, HistogramDataPoint): - self.assertEqual(point.count, 3) - histrogram_data_point_seen = True - self.assertAlmostEqual( - duration, point.sum, delta=100 - ) - self.assertDictEqual( - expected_duration_attributes, - dict(point.attributes), - ) - if isinstance(point, NumberDataPoint): - number_data_point_seen = True - self.assertEqual(point.value, 0) - self.assertDictEqual( - expected_requests_count_attributes, - dict(point.attributes), - ) + self.assertTrue(len(metrics) != 0) + for metric in metrics: + self.assertIn(metric.name, _expected_metric_names) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + histrogram_data_point_seen = True + self.assertAlmostEqual(duration, point.sum, delta=100) + self.assertDictEqual( + expected_duration_attributes, + dict(point.attributes), + ) + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + self.assertEqual(point.value, 0) + self.assertDictEqual( + expected_requests_count_attributes, + dict(point.attributes), + ) self.assertTrue(histrogram_data_point_seen and number_data_point_seen) # pylint: disable=too-many-locals @@ -912,41 +906,35 @@ def test_wsgi_metrics_new_semconv_custom_attributes(self): response = Client().get("/span_name_custom_attrs/1234/") self.assertEqual(response.status_code, 200) duration_s = default_timer() - start - metrics_list = self.memory_metrics_reader.get_metrics_data() + metrics = self.get_sorted_metrics(SCOPE) number_data_point_seen = False histrogram_data_point_seen = False - self.assertTrue(len(metrics_list.resource_metrics) != 0) - for resource_metric in metrics_list.resource_metrics: - self.assertTrue(len(resource_metric.scope_metrics) != 0) - for scope_metric in resource_metric.scope_metrics: - self.assertTrue(len(scope_metric.metrics) != 0) - for metric in scope_metric.metrics: - self.assertIn(metric.name, _expected_metric_names) - data_points = list(metric.data.data_points) - self.assertEqual(len(data_points), 1) - for point in data_points: - if isinstance(point, HistogramDataPoint): - self.assertEqual(point.count, 3) - histrogram_data_point_seen = True - self.assertAlmostEqual( - duration_s, point.sum, places=1 - ) - self.assertDictEqual( - expected_duration_attributes, - dict(point.attributes), - ) - self.assertEqual( - point.explicit_bounds, - HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, - ) - if isinstance(point, NumberDataPoint): - number_data_point_seen = True - self.assertEqual(point.value, 0) - self.assertDictEqual( - expected_requests_count_attributes, - dict(point.attributes), - ) + self.assertTrue(len(metrics) != 0) + for metric in metrics: + self.assertIn(metric.name, _expected_metric_names) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + histrogram_data_point_seen = True + self.assertAlmostEqual(duration_s, point.sum, places=1) + self.assertDictEqual( + expected_duration_attributes, + dict(point.attributes), + ) + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + self.assertEqual(point.value, 0) + self.assertDictEqual( + expected_requests_count_attributes, + dict(point.attributes), + ) self.assertTrue(histrogram_data_point_seen and number_data_point_seen) # pylint: disable=too-many-locals @@ -1073,54 +1061,46 @@ def test_wsgi_metrics_both_semconv_custom_attributes(self): self.assertEqual(response.status_code, 200) duration_s = max(default_timer() - start, 0) duration = max(round(duration_s * 1000), 0) - metrics_list = self.memory_metrics_reader.get_metrics_data() + metrics = self.get_sorted_metrics(SCOPE) number_data_point_seen = False histrogram_data_point_seen = False - self.assertTrue(len(metrics_list.resource_metrics) != 0) - for resource_metric in metrics_list.resource_metrics: - self.assertTrue(len(resource_metric.scope_metrics) != 0) - for scope_metric in resource_metric.scope_metrics: - self.assertTrue(len(scope_metric.metrics) != 0) - for metric in scope_metric.metrics: - self.assertIn(metric.name, _expected_metric_names) - data_points = list(metric.data.data_points) - self.assertEqual(len(data_points), 1) - for point in data_points: - if isinstance(point, HistogramDataPoint): - self.assertEqual(point.count, 3) - histrogram_data_point_seen = True - if metric.name == "http.server.request.duration": - self.assertAlmostEqual( - duration_s, point.sum, places=1 - ) - self.assertDictEqual( - expected_duration_attributes_new, - dict(point.attributes), - ) - self.assertEqual( - point.explicit_bounds, - HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, - ) - elif metric.name == "http.server.duration": - self.assertAlmostEqual( - duration, point.sum, delta=100 - ) - self.assertDictEqual( - expected_duration_attributes_old, - dict(point.attributes), - ) - self.assertEqual( - point.explicit_bounds, - HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, - ) - if isinstance(point, NumberDataPoint): - number_data_point_seen = True - self.assertEqual(point.value, 0) - self.assertDictEqual( - expected_requests_count_attributes, - dict(point.attributes), - ) + self.assertTrue(len(metrics) != 0) + for metric in metrics: + self.assertIn(metric.name, _expected_metric_names) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + histrogram_data_point_seen = True + if metric.name == "http.server.request.duration": + self.assertAlmostEqual(duration_s, point.sum, places=1) + self.assertDictEqual( + expected_duration_attributes_new, + dict(point.attributes), + ) + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + elif metric.name == "http.server.duration": + self.assertAlmostEqual(duration, point.sum, delta=100) + self.assertDictEqual( + expected_duration_attributes_old, + dict(point.attributes), + ) + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_OLD, + ) + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + self.assertEqual(point.value, 0) + self.assertDictEqual( + expected_requests_count_attributes, + dict(point.attributes), + ) self.assertTrue(histrogram_data_point_seen and number_data_point_seen) def test_wsgi_metrics_unistrument(self): diff --git a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py index 47752279fc..abc332b7ad 100644 --- a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py +++ b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py @@ -571,32 +571,28 @@ def test_falcon_metrics_custom_attributes(self): self.client().simulate_get("/user_custom_attr/123") self.client().simulate_get("/user_custom_attr/123") self.client().simulate_get("/user_custom_attr/123") - metrics_list = self.memory_metrics_reader.get_metrics_data() + metrics = self.get_sorted_metrics(SCOPE) number_data_point_seen = False histogram_data_point_seen = False - self.assertTrue(len(metrics_list.resource_metrics) != 0) - for resource_metric in metrics_list.resource_metrics: - self.assertTrue(len(resource_metric.scope_metrics) != 0) - for scope_metric in resource_metric.scope_metrics: - self.assertTrue(len(scope_metric.metrics) != 0) - for metric in scope_metric.metrics: - self.assertIn(metric.name, _expected_metric_names) - data_points = list(metric.data.data_points) - self.assertEqual(len(data_points), 1) - for point in data_points: - if isinstance(point, HistogramDataPoint): - self.assertEqual(point.count, 3) - histogram_data_point_seen = True - if isinstance(point, NumberDataPoint): - number_data_point_seen = True - for attr in point.attributes: - self.assertIn( - attr, - _recommended_metrics_attrs_old_with_custom[ - metric.name - ], - ) + self.assertTrue(len(metrics) != 0) + for metric in metrics: + self.assertIn(metric.name, _expected_metric_names) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_old_with_custom[ + metric.name + ], + ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) def test_falcon_metric_values_new_semconv(self): @@ -639,33 +635,29 @@ def test_falcon_metric_values_new_semconv_custom_attributes(self): self.client().simulate_get("/user_custom_attr/123") duration = max(default_timer() - start, 0) - metrics_list = self.memory_metrics_reader.get_metrics_data() - for resource_metric in metrics_list.resource_metrics: - for scope_metric in resource_metric.scope_metrics: - for metric in scope_metric.metrics: - data_points = list(metric.data.data_points) - self.assertEqual(len(data_points), 1) - for point in data_points: - if isinstance(point, HistogramDataPoint): - self.assertEqual(point.count, 1) - histogram_data_point_seen = True - self.assertAlmostEqual( - duration, point.sum, delta=10 - ) - self.assertEqual( - point.explicit_bounds, - HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, - ) - if isinstance(point, NumberDataPoint): - self.assertEqual(point.value, 0) - number_data_point_seen = True - for attr in point.attributes: - self.assertIn( - attr, - _recommended_metrics_attrs_new_with_custom[ - metric.name - ], - ) + metrics = self.get_sorted_metrics(SCOPE) + for metric in metrics: + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 1) + histogram_data_point_seen = True + self.assertAlmostEqual(duration, point.sum, delta=10) + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + if isinstance(point, NumberDataPoint): + self.assertEqual(point.value, 0) + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_new_with_custom[ + metric.name + ], + ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) def test_falcon_metric_values_both_semconv(self): diff --git a/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py b/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py index 2b91bff440..f519d23479 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py +++ b/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py @@ -563,34 +563,28 @@ def test_flask_metrics_custom_attributes(self): self.client.get("/test_labeler") self.client.get("/test_labeler") duration = max(round((default_timer() - start) * 1000), 0) - metrics_list = self.memory_metrics_reader.get_metrics_data() + metrics = self.get_sorted_metrics(SCOPE) number_data_point_seen = False histogram_data_point_seen = False - self.assertTrue(len(metrics_list.resource_metrics) != 0) - for resource_metric in metrics_list.resource_metrics: - self.assertTrue(len(resource_metric.scope_metrics) != 0) - for scope_metric in resource_metric.scope_metrics: - self.assertTrue(len(scope_metric.metrics) != 0) - for metric in scope_metric.metrics: - self.assertIn(metric.name, _expected_metric_names_old) - data_points = list(metric.data.data_points) - self.assertEqual(len(data_points), 1) - for point in data_points: - if isinstance(point, HistogramDataPoint): - self.assertEqual(point.count, 3) - self.assertAlmostEqual( - duration, point.sum, delta=10 - ) - histogram_data_point_seen = True - if isinstance(point, NumberDataPoint): - number_data_point_seen = True - for attr in point.attributes: - self.assertIn( - attr, - _recommended_metrics_attrs_old_with_custom[ - metric.name - ], - ) + self.assertTrue(len(metrics) != 0) + for metric in metrics: + self.assertIn(metric.name, _expected_metric_names_old) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + self.assertAlmostEqual(duration, point.sum, delta=10) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_old_with_custom[ + metric.name + ], + ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) def test_flask_metrics_new_semconv(self): @@ -631,38 +625,32 @@ def test_flask_metrics_custom_attributes_new_semconv(self): self.client.get("/test_labeler") self.client.get("/test_labeler") duration_s = max(default_timer() - start, 0) - metrics_list = self.memory_metrics_reader.get_metrics_data() + metrics = self.get_sorted_metrics(SCOPE) number_data_point_seen = False histogram_data_point_seen = False - self.assertTrue(len(metrics_list.resource_metrics) != 0) - for resource_metric in metrics_list.resource_metrics: - self.assertTrue(len(resource_metric.scope_metrics) != 0) - for scope_metric in resource_metric.scope_metrics: - self.assertTrue(len(scope_metric.metrics) != 0) - for metric in scope_metric.metrics: - self.assertIn(metric.name, _expected_metric_names_new) - data_points = list(metric.data.data_points) - self.assertEqual(len(data_points), 1) - for point in data_points: - if isinstance(point, HistogramDataPoint): - self.assertEqual(point.count, 3) - self.assertAlmostEqual( - duration_s, point.sum, places=1 - ) - self.assertEqual( - point.explicit_bounds, - HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, - ) - histogram_data_point_seen = True - if isinstance(point, NumberDataPoint): - number_data_point_seen = True - for attr in point.attributes: - self.assertIn( - attr, - _recommended_metrics_attrs_new_with_custom[ - metric.name - ], - ) + self.assertTrue(len(metrics) != 0) + for metric in metrics: + self.assertIn(metric.name, _expected_metric_names_new) + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + for point in data_points: + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 3) + self.assertAlmostEqual(duration_s, point.sum, places=1) + self.assertEqual( + point.explicit_bounds, + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, + _recommended_metrics_attrs_new_with_custom[ + metric.name + ], + ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) def test_flask_metric_values(self): From 12ef547ecf048e8296ad60ac73376352699b09bb Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 23 Feb 2026 17:33:31 -0800 Subject: [PATCH 42/42] Test semcomv no override --- .../tests/test_asgi_middleware.py | 69 +++++++++++++++++++ .../tests/test_wsgi_middleware.py | 59 ++++++++++++++++ .../tests/test_labeler.py | 24 +++++++ 3 files changed, 152 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py index 9ae8419baf..d44fc3e2e8 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py @@ -332,6 +332,29 @@ async def custom_attrs_asgi(scope, receive, send): await send({"type": "http.response.body", "body": b"*"}) +async def override_attrs_asgi(scope, receive, send): + assert isinstance(scope, dict) + assert scope["type"] == "http" + labeler = get_labeler() + labeler.add("custom_attr", "test_value") + labeler.add("http.method", "POST") + labeler.add("http.request.method", "POST") + message = await receive() + scope["headers"] = [(b"content-length", b"128")] + if message.get("type") == "http.request": + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + [b"Content-Type", b"text/plain"], + [b"content-length", b"1024"], + ], + } + ) + await send({"type": "http.response.body", "body": b"*"}) + + async def error_asgi(scope, receive, send): assert isinstance(scope, dict) assert scope["type"] == "http" @@ -1593,6 +1616,29 @@ async def test_asgi_metrics_custom_attributes(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + async def test_asgi_metrics_custom_attributes_skip_override_old_semconv( + self, + ): + app = otel_asgi.OpenTelemetryMiddleware(override_attrs_asgi) + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + + metrics = self.get_sorted_metrics(SCOPE) + histogram_point_seen = False + for metric in metrics: + if metric.name != "http.server.duration": + continue + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + point = data_points[0] + self.assertIsInstance(point, HistogramDataPoint) + self.assertEqual(point.attributes[HTTP_METHOD], "GET") + self.assertEqual(point.attributes["custom_attr"], "test_value") + histogram_point_seen = True + + self.assertTrue(histogram_point_seen) + async def test_asgi_metrics_new_semconv(self): # pylint: disable=too-many-nested-blocks app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) @@ -1628,6 +1674,29 @@ async def test_asgi_metrics_new_semconv(self): self.assertIn(attr, _recommended_attrs_new[metric.name]) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + async def test_asgi_metrics_custom_attributes_skip_override_new_semconv( + self, + ): + app = otel_asgi.OpenTelemetryMiddleware(override_attrs_asgi) + self.seed_app(app) + await self.send_default_request() + await self.get_all_output() + + metrics = self.get_sorted_metrics(SCOPE) + histogram_point_seen = False + for metric in metrics: + if metric.name != "http.server.request.duration": + continue + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + point = data_points[0] + self.assertIsInstance(point, HistogramDataPoint) + self.assertEqual(point.attributes[HTTP_REQUEST_METHOD], "GET") + self.assertEqual(point.attributes["custom_attr"], "test_value") + histogram_point_seen = True + + self.assertTrue(histogram_point_seen) + async def test_asgi_metrics_new_semconv_custom_attributes(self): # pylint: disable=too-many-nested-blocks app = otel_asgi.OpenTelemetryMiddleware(custom_attrs_asgi) diff --git a/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py b/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py index 071e758ee4..9b5568eff8 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py @@ -154,6 +154,15 @@ def error_wsgi_unhandled_custom_attrs(environ, start_response): raise ValueError +def error_wsgi_unhandled_override_attrs(environ, start_response): + labeler = get_labeler() + labeler.add("custom_attr", "test_value") + labeler.add("http.method", "POST") + labeler.add("http.request.method", "POST") + assert isinstance(environ, dict) + raise ValueError + + def wsgi_with_custom_response_headers(environ, start_response): assert isinstance(environ, dict) start_response( @@ -519,6 +528,31 @@ def test_wsgi_metrics_custom_attributes(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_wsgi_metrics_custom_attributes_skip_override_old_semconv(self): + app = otel_wsgi.OpenTelemetryMiddleware( + error_wsgi_unhandled_override_attrs + ) + self.assertRaises(ValueError, app, self.environ, self.start_response) + + metrics = self.get_sorted_metrics(SCOPE) + histogram_point_seen = False + + for metric in metrics: + if metric.name != "http.server.duration": + continue + + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + point = data_points[0] + self.assertIsInstance(point, HistogramDataPoint) + # Not overridden as "POST" from usage of Labeler + self.assertEqual(point.attributes[HTTP_METHOD], "GET") + # Still writes custom_attr from Labeler + self.assertEqual(point.attributes["custom_attr"], "test_value") + histogram_point_seen = True + + self.assertTrue(histogram_point_seen) + def test_wsgi_metrics_exemplars_expected_old_semconv(self): # type: ignore[func-returns-value] """Failing test asserting exemplars should be present for duration histogram (old semconv).""" app = otel_wsgi.OpenTelemetryMiddleware(simple_wsgi) @@ -588,6 +622,31 @@ def test_wsgi_metrics_new_semconv(self): ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) + def test_wsgi_metrics_custom_attributes_skip_override_new_semconv(self): + app = otel_wsgi.OpenTelemetryMiddleware( + error_wsgi_unhandled_override_attrs + ) + self.assertRaises(ValueError, app, self.environ, self.start_response) + + metrics = self.get_sorted_metrics(SCOPE) + histogram_point_seen = False + + for metric in metrics: + if metric.name != "http.server.request.duration": + continue + + data_points = list(metric.data.data_points) + self.assertEqual(len(data_points), 1) + point = data_points[0] + self.assertIsInstance(point, HistogramDataPoint) + # Not overridden as "POST" from usage of Labeler + self.assertEqual(point.attributes[HTTP_REQUEST_METHOD], "GET") + # Still writes custom_attr from Labeler + self.assertEqual(point.attributes["custom_attr"], "test_value") + histogram_point_seen = True + + self.assertTrue(histogram_point_seen) + def test_wsgi_metrics_new_semconv_custom_attributes(self): # pylint: disable=too-many-nested-blocks app = otel_wsgi.OpenTelemetryMiddleware(error_wsgi_unhandled) diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py index 29cc2b9f76..359e92e1da 100644 --- a/opentelemetry-instrumentation/tests/test_labeler.py +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -21,6 +21,7 @@ from opentelemetry.instrumentation._labeler import ( Labeler, clear_labeler, + enrich_metric_attributes, get_labeler, get_labeler_attributes, set_labeler, @@ -194,6 +195,29 @@ def test_add_attributes_all_invalid_types(self): self.assertEqual(attributes, {}) self.assertEqual(len(labeler), 0) + def test_enrich_metric_attributes_skips_base_key_overrides(self): + base_attributes = { + "http.method": "GET", + "http.status_code": 200, + } + + labeler = get_labeler() + labeler.add("http.method", "POST") + labeler.add("custom_attr", "test-value") + + enriched = enrich_metric_attributes(base_attributes) + + self.assertEqual(enriched["http.method"], "GET") + self.assertEqual(enriched["http.status_code"], 200) + self.assertEqual(enriched["custom_attr"], "test-value") + self.assertEqual( + base_attributes, + { + "http.method": "GET", + "http.status_code": 200, + }, + ) + def test_thread_safety(self): labeler = Labeler(max_custom_attrs=1100) # 11 * 100 num_threads = 10