diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e438ce0b6..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 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 9df2f4ec58..e5a5c9ea4f 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,46 @@ 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 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 + + .. 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("/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__": + app.run(debug=True) + API --- """ @@ -220,6 +260,10 @@ 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 ( + enrich_metric_attributes, + get_labeler, +) from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, _filter_semconv_active_request_count_attr, @@ -728,6 +772,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) @@ -809,11 +856,19 @@ async def __call__( duration_attrs_old = _parse_duration_attrs( attributes, _StabilityMode.DEFAULT ) + # Enhance attributes with any custom labeler attributes + duration_attrs_old = enrich_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 = enrich_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 0da3014f5f..d7561b3ad6 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" @@ -292,6 +352,7 @@ def hook(*_): class TestAsgiApplication(AsyncAsgiTestBase): def setUp(self): super().setUp() + clear_labeler() test_name = "" if hasattr(self, "_testMethodName"): @@ -1302,6 +1363,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) @@ -1347,6 +1459,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) @@ -1392,6 +1552,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) 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 ebc3e08f4d..64de2ad51a 100644 --- a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py @@ -229,6 +229,40 @@ def response_hook(span, request, response): :: Enabling this flag will add name of the db driver /*db_driver='django.db.backends.postgresql'*/ + +Custom Metrics Attributes using Labeler +*************************************** +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 + + from django.http import HttpResponse + from opentelemetry.instrumentation._labeler import get_labeler + from opentelemetry.instrumentation.django import DjangoInstrumentor + + DjangoInstrumentor().instrument() + + # Note: urlpattern `/users//` 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 --- 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..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,6 +22,7 @@ from django.http import HttpRequest, HttpResponse from opentelemetry.context import detach +from opentelemetry.instrumentation._labeler import enrich_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 = enrich_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 = enrich_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..9159299109 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_custom_attrs/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" 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..c243b798be 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,44 @@ 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 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('/users/{user_id}/', UserProfileResource()) + API --- """ @@ -195,6 +233,7 @@ def response_hook(span, req, resp): import opentelemetry.instrumentation.wsgi as otel_wsgi from opentelemetry import context, trace +from opentelemetry.instrumentation._labeler import enrich_metric_attributes from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, _get_schema_url, @@ -420,6 +459,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 = enrich_metric_attributes(duration_attrs) self.duration_histogram_old.record( max(round(duration_s * 1000), 0), duration_attrs ) @@ -427,6 +468,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 = enrich_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..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 @@ -21,6 +24,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 +107,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 +571,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 +639,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 +731,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) 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 9a22383e1b..cd5378ac74 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py @@ -43,6 +43,43 @@ 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 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._labeler import get_labeler + from opentelemetry.instrumentation.flask import FlaskInstrumentor + + app = Flask(__name__) + FlaskInstrumentor().instrument_app(app) + + @app.route("/users//") + 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 ------------- @@ -253,6 +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 enrich_metric_attributes from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, _get_schema_url, @@ -346,7 +384,6 @@ def _wrapped_app(wrapped_app_environ, start_response): sem_conv_opt_in_mode, ) ) - active_requests_counter.add(1, active_requests_count_attrs) request_route = None @@ -405,6 +442,9 @@ def _start_response(status, response_headers, *args, **kwargs): # http.target to be included in old semantic conventions 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_histogram_old.record( max(round(duration_s * 1000), 0), duration_attrs_old ) @@ -416,6 +456,9 @@ 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 = enrich_metric_attributes(duration_attrs_new) + duration_histogram_new.record( max(duration_s, 0), duration_attrs_new ) diff --git a/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py b/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py index 60f08c7cd2..760e2eef11 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, @@ -146,6 +150,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): @@ -177,7 +198,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() @@ -525,6 +562,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") @@ -563,6 +636,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") 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..2dab70e218 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,55 @@ def GET(self): ) server.start() +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 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 web + from cheroot import wsgi + + from opentelemetry.instrumentation._labeler import get_labeler + from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware + + 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") + + # 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 = web.application(urls, globals()) + func = app.wsgifunc() + + func = OpenTelemetryMiddleware(func) + + server = wsgi.WSGIServer( + ("localhost", 5100), func, server_name="localhost" + ) + server.start() + + Configuration ------------- @@ -223,6 +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 enrich_metric_attributes from opentelemetry.instrumentation._semconv import ( HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, _filter_semconv_active_request_count_attr, @@ -712,6 +762,10 @@ def __call__( duration_attrs_old = _parse_duration_attrs( req_attrs, _StabilityMode.DEFAULT ) + # Enhance attributes with any custom labeler attributes + duration_attrs_old = enrich_metric_attributes( + duration_attrs_old + ) self.duration_histogram_old.record( max(round(duration_s * 1000), 0), duration_attrs_old ) @@ -719,6 +773,10 @@ def __call__( duration_attrs_new = _parse_duration_attrs( req_attrs, _StabilityMode.HTTP ) + # Enhance attributes with any custom labeler attributes + duration_attrs_new = enrich_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) 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..baaa37ca78 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/__init__.py @@ -0,0 +1,85 @@ +# 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. + +""" +OpenTelemetry Labeler +===================== + +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 + +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 programmatic 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(): + 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. + +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, + enrich_metric_attributes, + get_labeler, + get_labeler_attributes, + set_labeler, +) + +__all__ = [ + "Labeler", + "get_labeler", + "set_labeler", + "clear_labeler", + "get_labeler_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 new file mode 100644 index 0000000000..9ea3050562 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_labeler/_internal/__init__.py @@ -0,0 +1,235 @@ +# 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 contextvars +import logging +import threading +from types import MappingProxyType +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) +) + +_logger = logging.getLogger(__name__) + + +class Labeler: + """ + Stores custom attributes for the current request in context. + + This feature is experimental and unstable. + """ + + 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, 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: 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 + 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, 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. Values must be primitive types + (str, int, float, or bool) + """ + 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 + ): + 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]]: + """ + Returns a copy of all attributes added to the labeler. + """ + with self._lock: + return MappingProxyType(self._attributes) + + 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 MappingProxyType({}) + return labeler.get_attributes() + + +def enrich_metric_attributes( + base_attributes: Dict[str, Any], + enrich_enabled: bool = True, +) -> Dict[str, Any]: + """ + Combines base_attributes with custom attributes from the current labeler, + 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 + 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 enrich_enabled: + return base_attributes.copy() + + 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() + + enriched_attributes = base_attributes.copy() + + added_count = 0 + for key, value in custom_attributes.items(): + if added_count >= labeler._max_custom_attrs: + break + if key in base_attributes: + continue + + if ( + isinstance(value, str) + and len(value) > labeler._max_attr_value_length + ): + value = value[: labeler._max_attr_value_length] + + enriched_attributes[key] = value + added_count += 1 + + return enriched_attributes diff --git a/opentelemetry-instrumentation/tests/test_labeler.py b/opentelemetry-instrumentation/tests/test_labeler.py new file mode 100644 index 0000000000..acb3218b28 --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_labeler.py @@ -0,0 +1,366 @@ +# 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 +import unittest +from unittest.mock import patch + +from opentelemetry.instrumentation._labeler import ( + Labeler, + clear_labeler, + get_labeler, + get_labeler_attributes, + set_labeler, +) +from opentelemetry.instrumentation._labeler._internal import _labeler_context + + +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_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_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 + num_operations = 100 + + def worker(thread_id): + for i_operation in range(num_operations): + labeler.add( + 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 = [] + 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() + + attributes = labeler.get_attributes() + # 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): + 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_operation in range(3): + ctx = contextvars.copy_context() + ctx.run(context_worker, i_operation, results) + + # Each context should have its own labeler with its own values + for i_operation in range(3): + expected = { + "context_id": i_operation, + "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(), {})