From 1e78378aa896f10ce012186dc4cfc67fa4b01994 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 28 Nov 2025 17:55:18 -0800 Subject: [PATCH 1/5] wip --- .../instrumentation/pyramid/__init__.py | 13 +++ .../instrumentation/pyramid/callbacks.py | 105 +++++++++++++++--- 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/__init__.py b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/__init__.py index dd71acb3a9..352ac9d49c 100644 --- a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/__init__.py @@ -191,7 +191,12 @@ from pyramid.settings import aslist from wrapt import wrap_function_wrapper as _wrap +from opentelemetry.instrumentation._semconv import ( + _OpenTelemetrySemanticConventionStability, + _OpenTelemetryStabilitySignalType, +) from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.pyramid import callbacks from opentelemetry.instrumentation.pyramid.callbacks import ( SETTING_TRACE_ENABLED, TWEEN_NAME, @@ -248,6 +253,14 @@ def _instrument(self, **kwargs): """Integrate with Pyramid Python library. https://docs.pylonsproject.org/projects/pyramid/en/latest/ """ + # Initialize semantic conventions opt-in mode + _OpenTelemetrySemanticConventionStability._initialize() + sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP, + ) + # Set module-level opt-in mode in callbacks + callbacks._sem_conv_opt_in_mode = sem_conv_opt_in_mode + _wrap("pyramid.config", "Configurator.__init__", _traced_init) def _uninstrument(self, **kwargs): diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py index 6a526f2235..ddcf16f2e6 100644 --- a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py +++ b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py @@ -23,13 +23,24 @@ import opentelemetry.instrumentation.wsgi as otel_wsgi from opentelemetry import context, trace +from opentelemetry.instrumentation._semconv import ( + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + _get_schema_url, + _report_new, + _report_old, + _StabilityMode, +) from opentelemetry.instrumentation.propagators import ( get_global_response_propagator, ) from opentelemetry.instrumentation.pyramid.version import __version__ from opentelemetry.instrumentation.utils import _start_internal_or_server_span from opentelemetry.metrics import get_meter +from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE from opentelemetry.semconv.metrics import MetricInstruments +from opentelemetry.semconv.metrics.http_metrics import ( + HTTP_SERVER_REQUEST_DURATION, +) from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.trace.status import Status, StatusCode from opentelemetry.util.http import get_excluded_urls @@ -47,6 +58,7 @@ _excluded_urls = get_excluded_urls("PYRAMID") +_sem_conv_opt_in_mode = _StabilityMode.DEFAULT def includeme(config): @@ -88,7 +100,7 @@ def _before_traversal(event): tracer = trace.get_tracer( __name__, __version__, - schema_url="https://opentelemetry.io/schemas/1.11.0", + schema_url=_get_schema_url(_sem_conv_opt_in_mode), ) if request.matched_route: @@ -105,7 +117,9 @@ def _before_traversal(event): ) if span.is_recording(): - attributes = otel_wsgi.collect_request_attributes(request_environ) + attributes = otel_wsgi.collect_request_attributes( + request_environ, _sem_conv_opt_in_mode + ) if request.matched_route: attributes[SpanAttributes.HTTP_ROUTE] = ( request.matched_route.pattern @@ -133,20 +147,45 @@ def trace_tween_factory(handler, registry): # pylint: disable=too-many-statements settings = registry.settings enabled = asbool(settings.get(SETTING_TRACE_ENABLED, True)) + + # Create meters and histograms based on opt-in mode + duration_histogram_old = None + if _report_old(_sem_conv_opt_in_mode): + meter_old = get_meter( + __name__, + __version__, + schema_url=_get_schema_url(_StabilityMode.DEFAULT), + ) + duration_histogram_old = meter_old.create_histogram( + name=MetricInstruments.HTTP_SERVER_DURATION, + unit="ms", + description="Measures the duration of inbound HTTP requests.", + ) + + duration_histogram_new = None + if _report_new(_sem_conv_opt_in_mode): + meter_new = get_meter( + __name__, + __version__, + schema_url=_get_schema_url(_StabilityMode.HTTP), + ) + duration_histogram_new = meter_new.create_histogram( + name=HTTP_SERVER_REQUEST_DURATION, + unit="s", + description="Duration of HTTP server requests.", + explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + + # Use a single meter for active requests counter (attributes are compatible) meter = get_meter( __name__, __version__, - schema_url="https://opentelemetry.io/schemas/1.11.0", - ) - duration_histogram = meter.create_histogram( - name=MetricInstruments.HTTP_SERVER_DURATION, - unit="ms", - description="Measures the duration of inbound HTTP requests.", + schema_url=_get_schema_url(_sem_conv_opt_in_mode), ) active_requests_counter = meter.create_up_down_counter( name=MetricInstruments.HTTP_SERVER_ACTIVE_REQUESTS, - unit="requests", - description="measures the number of concurrent HTTP requests that are currently in-flight", + unit="{request}", + description="Number of active HTTP server requests.", ) if not enabled: @@ -167,14 +206,20 @@ def trace_tween(request): # short-circuit when we don't want to trace anything return handler(request) - attributes = otel_wsgi.collect_request_attributes(request.environ) + attributes = otel_wsgi.collect_request_attributes( + request.environ, _sem_conv_opt_in_mode + ) request.environ[_ENVIRON_ENABLED_KEY] = True request.environ[_ENVIRON_STARTTIME_KEY] = time_ns() active_requests_count_attrs = ( - otel_wsgi._parse_active_request_count_attrs(attributes) + otel_wsgi._parse_active_request_count_attrs( + attributes, _sem_conv_opt_in_mode + ) + ) + duration_attrs = otel_wsgi._parse_duration_attrs( + attributes, _sem_conv_opt_in_mode ) - duration_attrs = otel_wsgi._parse_duration_attrs(attributes) start = default_timer() active_requests_counter.add(1, active_requests_count_attrs) @@ -200,16 +245,34 @@ def trace_tween(request): # should infer a internal server error and raise status = "500 InternalServerError" recordable_exc = exc + if _report_new(_sem_conv_opt_in_mode): + attributes[ERROR_TYPE] = type(exc).__qualname__ raise finally: - duration = max(round((default_timer() - start) * 1000), 0) + duration_s = default_timer() - start status = getattr(response, "status", status) status_code = otel_wsgi._parse_status_code(status) if status_code is not None: - duration_attrs[SpanAttributes.HTTP_STATUS_CODE] = ( - otel_wsgi._parse_status_code(status) + duration_attrs[SpanAttributes.HTTP_STATUS_CODE] = status_code + + # Record metrics for old semconv (milliseconds) + if duration_histogram_old: + duration_attrs_old = otel_wsgi._parse_duration_attrs( + duration_attrs, _StabilityMode.DEFAULT ) - duration_histogram.record(duration, duration_attrs) + duration_histogram_old.record( + max(round(duration_s * 1000), 0), duration_attrs_old + ) + + # Record metrics for new semconv (seconds) + if duration_histogram_new: + duration_attrs_new = otel_wsgi._parse_duration_attrs( + duration_attrs, _StabilityMode.HTTP + ) + duration_histogram_new.record( + max(duration_s, 0), duration_attrs_new + ) + active_requests_counter.add(-1, active_requests_count_attrs) span = request.environ.get(_ENVIRON_SPAN_KEY) enabled = request.environ.get(_ENVIRON_ENABLED_KEY) @@ -225,9 +288,17 @@ def trace_tween(request): span, status, getattr(response, "headerlist", None), + duration_attrs, + _sem_conv_opt_in_mode, ) if recordable_exc is not None: + if _report_new(_sem_conv_opt_in_mode): + if span.is_recording(): + span.set_attribute( + ERROR_TYPE, + type(recordable_exc).__qualname__, + ) span.set_status( Status(StatusCode.ERROR, str(recordable_exc)) ) From c897bdb30e59eec621bca0df1912b78416bfbbec Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 28 Nov 2025 18:27:32 -0800 Subject: [PATCH 2/5] Fix opt-in mode set at init --- .../instrumentation/pyramid/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/__init__.py b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/__init__.py index 352ac9d49c..d493b434b4 100644 --- a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/__init__.py @@ -194,6 +194,7 @@ from opentelemetry.instrumentation._semconv import ( _OpenTelemetrySemanticConventionStability, _OpenTelemetryStabilitySignalType, + _StabilityMode, ) from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.pyramid import callbacks @@ -265,6 +266,8 @@ def _instrument(self, **kwargs): def _uninstrument(self, **kwargs): """ "Disable Pyramid instrumentation""" + # Reset module-level opt-in mode to default + callbacks._sem_conv_opt_in_mode = _StabilityMode.DEFAULT unwrap(Configurator, "__init__") @staticmethod @@ -274,8 +277,18 @@ def instrument_config(config): Args: config: The Configurator to instrument. """ + # Initialize semantic conventions opt-in mode + _OpenTelemetrySemanticConventionStability._initialize() + sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP, + ) + # Set module-level opt-in mode in callbacks + callbacks._sem_conv_opt_in_mode = sem_conv_opt_in_mode + config.include("opentelemetry.instrumentation.pyramid.callbacks") @staticmethod def uninstrument_config(config): + # Reset module-level opt-in mode to default + callbacks._sem_conv_opt_in_mode = _StabilityMode.DEFAULT config.add_settings({SETTING_TRACE_ENABLED: False}) From d2772ea7251de179d084b0a56b06ff3b8bbff30c Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 28 Nov 2025 18:42:39 -0800 Subject: [PATCH 3/5] Add tests --- .../tests/test_automatic.py | 207 ++++++++++++++++++ .../tests/test_programmatic.py | 6 + 2 files changed, 213 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py b/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py index b40cf3355a..f3afa2fa01 100644 --- a/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py +++ b/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py @@ -19,14 +19,26 @@ from opentelemetry import trace from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, _server_active_requests_count_attrs_old, _server_duration_attrs_old, + _StabilityMode, ) from opentelemetry.instrumentation.pyramid import PyramidInstrumentor from opentelemetry.sdk.metrics.export import ( HistogramDataPoint, NumberDataPoint, ) +from opentelemetry.semconv._incubating.attributes.http_attributes import ( + HTTP_ROUTE, +) +from opentelemetry.semconv.attributes.server_attributes import ( + SERVER_ADDRESS, + SERVER_PORT, +) +from opentelemetry.semconv.attributes.url_attributes import URL_SCHEME +from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.test.globals_test import reset_trace_globals from opentelemetry.test.wsgitestutil import WsgiTestBase from opentelemetry.trace import SpanKind @@ -409,6 +421,201 @@ def test_custom_response_header_not_added_in_internal_span(self): self.assertNotIn(key, span.attributes) +class _SemConvTestBase(InstrumentationTest, WsgiTestBase): + semconv_mode = _StabilityMode.DEFAULT + + def setUp(self): + super().setUp() + self.env_patch = patch.dict( + "os.environ", + {OTEL_SEMCONV_STABILITY_OPT_IN: self.semconv_mode.value}, + ) + _OpenTelemetrySemanticConventionStability._initialized = False + self.env_patch.start() + + PyramidInstrumentor().instrument() + self.config = Configurator() + self._common_initialization(self.config) + + def tearDown(self): + super().tearDown() + self.env_patch.stop() + with self.disable_logging(): + PyramidInstrumentor().uninstrument() + + def _verify_metric_names( + self, metrics_list, expected_names, not_expected_names=None + ): + metric_names = [] + for resource_metric in metrics_list.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + metric_names.append(metric.name) + if expected_names: + self.assertIn(metric.name, expected_names) + if not_expected_names: + self.assertNotIn(metric.name, not_expected_names) + return metric_names + + def _verify_duration_point(self, point): + self.assertIn("http.request.method", point.attributes) + self.assertIn("url.scheme", point.attributes) + self.assertNotIn("http.method", point.attributes) + self.assertNotIn("http.scheme", point.attributes) + + def _verify_metric_duration(self, metric): + if "duration" in metric.name: + for point in metric.data.data_points: + if isinstance(point, HistogramDataPoint): + self._verify_duration_point(point) + + def _verify_duration_attributes(self, metrics_list): + for resource_metric in metrics_list.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + self._verify_metric_duration(metric) + + +class TestSemConvDefault(_SemConvTestBase): + semconv_mode = _StabilityMode.DEFAULT + + def test_basic_old_semconv(self): + resp = self.client.get("/hello/123") + self.assertEqual(200, resp.status_code) + + span = self.memory_exporter.get_finished_spans()[0] + + old_attrs = { + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_SCHEME: "http", + SpanAttributes.HTTP_HOST: "localhost", + SpanAttributes.HTTP_TARGET: "/hello/123", + SpanAttributes.NET_HOST_PORT: 80, + SpanAttributes.HTTP_STATUS_CODE: 200, + SpanAttributes.HTTP_FLAVOR: "1.1", + HTTP_ROUTE: "/hello/{helloid}", + } + for attr, value in old_attrs.items(): + self.assertEqual(span.attributes[attr], value) + + for attr in [SERVER_ADDRESS, SERVER_PORT, URL_SCHEME]: + self.assertNotIn(attr, span.attributes) + + def test_metrics_old_semconv(self): + self.client.get("/hello/123") + + metrics_list = self.memory_metrics_reader.get_metrics_data() + self.assertTrue(len(metrics_list.resource_metrics) == 1) + + expected_metrics = [ + "http.server.active_requests", + "http.server.duration", + ] + self._verify_metric_names( + metrics_list, expected_metrics, ["http.server.request.duration"] + ) + + for resource_metric in metrics_list.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + for point in metric.data.data_points: + if isinstance(point, HistogramDataPoint): + self.assertIn("http.method", point.attributes) + self.assertIn("http.scheme", point.attributes) + self.assertNotIn( + "http.request.method", point.attributes + ) + + +class TestSemConvNew(_SemConvTestBase): + semconv_mode = _StabilityMode.HTTP + + def test_basic_new_semconv(self): + resp = self.client.get("/hello/456") + self.assertEqual(200, resp.status_code) + + span = self.memory_exporter.get_finished_spans()[0] + + new_attrs = { + "http.request.method": "GET", + URL_SCHEME: "http", + SERVER_ADDRESS: "localhost", + SERVER_PORT: 80, + "http.response.status_code": 200, + "network.protocol.version": "1.1", + HTTP_ROUTE: "/hello/{helloid}", + } + for attr, value in new_attrs.items(): + self.assertEqual(span.attributes[attr], value) + + old_attrs = [ + SpanAttributes.HTTP_METHOD, + SpanAttributes.HTTP_HOST, + SpanAttributes.HTTP_TARGET, + SpanAttributes.HTTP_URL, + ] + for attr in old_attrs: + self.assertNotIn(attr, span.attributes) + + def test_metrics_new_semconv(self): + self.client.get("/hello/456") + metrics_list = self.memory_metrics_reader.get_metrics_data() + self.assertTrue(len(metrics_list.resource_metrics) == 1) + + expected_metrics = [ + "http.server.request.duration", + "http.server.active_requests", + ] + metric_names = self._verify_metric_names( + metrics_list, expected_metrics + ) + + self.assertIn("http.server.request.duration", metric_names) + self.assertIn("http.server.active_requests", metric_names) + + self._verify_duration_attributes(metrics_list) + + +class TestSemConvDup(_SemConvTestBase): + semconv_mode = _StabilityMode.HTTP_DUP + + def test_basic_both_semconv(self): + resp = self.client.get("/hello/789") + self.assertEqual(200, resp.status_code) + + span = self.memory_exporter.get_finished_spans()[0] + + expected_attrs = { + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_SCHEME: "http", + SpanAttributes.HTTP_HOST: "localhost", + SpanAttributes.HTTP_STATUS_CODE: 200, + "http.request.method": "GET", + URL_SCHEME: "http", + SERVER_ADDRESS: "localhost", + "http.response.status_code": 200, + HTTP_ROUTE: "/hello/{helloid}", + } + for attr, value in expected_attrs.items(): + self.assertEqual(span.attributes[attr], value) + + def test_metrics_both_semconv(self): + self.client.get("/hello/789") + + metrics_list = self.memory_metrics_reader.get_metrics_data() + self.assertTrue(len(metrics_list.resource_metrics) == 1) + + expected_metrics = [ + "http.server.duration", + "http.server.request.duration", + "http.server.active_requests", + ] + metric_names = self._verify_metric_names(metrics_list, None) + + for metric_name in expected_metrics: + self.assertIn(metric_name, metric_names) + + @patch.dict( "os.environ", { diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_programmatic.py b/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_programmatic.py index 0e3a5dec19..32de8f92eb 100644 --- a/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_programmatic.py +++ b/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_programmatic.py @@ -17,6 +17,9 @@ from pyramid.config import Configurator from opentelemetry import trace +from opentelemetry.instrumentation._semconv import ( + _OpenTelemetrySemanticConventionStability, +) from opentelemetry.instrumentation.propagators import ( TraceResponsePropagator, get_global_response_propagator, @@ -52,6 +55,9 @@ def expected_attributes(override_attributes): class TestProgrammatic(InstrumentationTest, WsgiTestBase): def setUp(self): super().setUp() + # Reset semconv stability to ensure clean state + _OpenTelemetrySemanticConventionStability._initialized = False + config = Configurator() PyramidInstrumentor().instrument_config(config) From 830c714f22771c60e9ae8bc94b3f00338e37fbe8 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 28 Nov 2025 18:43:48 -0800 Subject: [PATCH 4/5] Changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cbbe7aa3b..48f6154296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3967](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3967)) - `opentelemetry-instrumentation-redis`: add missing copyright header for opentelemetry-instrumentation-redis ([#3976](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3976)) +- `opentelemetry-instrumentation-pyramid` Implement new semantic convention opt-in migration + ([#3982](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3982)) ### Fixed From 896b4856a395db7d4d91877f3f73f96ee454bfeb Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Tue, 2 Dec 2025 10:17:57 -0800 Subject: [PATCH 5/5] Style --- .../instrumentation/pyramid/callbacks.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py index ddcf16f2e6..5ac86af029 100644 --- a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py +++ b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py @@ -293,12 +293,14 @@ def trace_tween(request): ) if recordable_exc is not None: - if _report_new(_sem_conv_opt_in_mode): - if span.is_recording(): - span.set_attribute( - ERROR_TYPE, - type(recordable_exc).__qualname__, - ) + if ( + _report_new(_sem_conv_opt_in_mode) + and span.is_recording() + ): + span.set_attribute( + ERROR_TYPE, + type(recordable_exc).__qualname__, + ) span.set_status( Status(StatusCode.ERROR, str(recordable_exc)) )