diff --git a/CHANGELOG.md b/CHANGELOG.md index 146b15a5f0..6db0e04e68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased -- `opentelemetry-sdk-extension-aws` Register AWS resource detectors under the - `opentelemetry_resource_detector` entry point - ([#2382](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2382)) ### Breaking changes @@ -21,6 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `opentelemetry-sdk-extension-aws` Register AWS resource detectors under the + `opentelemetry_resource_detector` entry point + ([#2382](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2382)) +- `opentelemetry-instrumentation-wsgi` Implement new semantic convention opt-in with stable http semantic conventions + ([#2425](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2425)) - `opentelemetry-instrumentation-threading` Initial release for threading ([#2253](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2253)) @@ -29,6 +31,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `opentelemetry-instrumentation-grpc` AioClientInterceptor should propagate with a Metadata object ([#2363](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2363)) +### Added + +- `opentelemetry-sdk-extension-aws` Register AWS resource detectors under the `opentelemetry_resource_detector` entry point + ([#2382](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2382)) +- `opentelemetry-instrumentation-wsgi` Implement new semantic convention opt-in with stable http semantic conventions + ([#2425](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2425)) +- `opentelemetry-instrumentation-threading` Initial release for threading + ([#2253](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2253)) + ## Version 1.24.0/0.45b0 (2024-03-28) ### Added diff --git a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py index 2245dbfd80..bf7f1d4f49 100644 --- a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py +++ b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py @@ -16,21 +16,21 @@ from unittest.mock import Mock, patch import pytest -from falcon import __version__ as _falcon_verison +from falcon import __version__ as _falcon_version from falcon import testing from packaging import version as package_version from opentelemetry import trace +from opentelemetry.instrumentation._semconv import ( + _server_active_requests_count_attrs_old, + _server_duration_attrs_old, +) from opentelemetry.instrumentation.falcon import FalconInstrumentor from opentelemetry.instrumentation.propagators import ( TraceResponsePropagator, get_global_response_propagator, set_global_response_propagator, ) -from opentelemetry.instrumentation.wsgi import ( - _active_requests_count_attrs, - _duration_attrs, -) from opentelemetry.sdk.metrics.export import ( HistogramDataPoint, NumberDataPoint, @@ -53,8 +53,8 @@ "http.server.duration", ] _recommended_attrs = { - "http.server.active_requests": _active_requests_count_attrs, - "http.server.duration": _duration_attrs, + "http.server.active_requests": _server_active_requests_count_attrs_old, + "http.server.duration": _server_duration_attrs_old, } @@ -125,7 +125,7 @@ def _test_method(self, method): SpanAttributes.NET_HOST_PORT: 80, SpanAttributes.HTTP_HOST: "falconframework.org", SpanAttributes.HTTP_TARGET: "/", - SpanAttributes.NET_PEER_PORT: "65133", + SpanAttributes.NET_PEER_PORT: 65133, SpanAttributes.HTTP_FLAVOR: "1.1", "falcon.resource": "HelloWorldResource", SpanAttributes.HTTP_STATUS_CODE: 201, @@ -156,7 +156,7 @@ def test_404(self): SpanAttributes.NET_HOST_PORT: 80, SpanAttributes.HTTP_HOST: "falconframework.org", SpanAttributes.HTTP_TARGET: "/", - SpanAttributes.NET_PEER_PORT: "65133", + SpanAttributes.NET_PEER_PORT: 65133, SpanAttributes.HTTP_FLAVOR: "1.1", SpanAttributes.HTTP_STATUS_CODE: 404, }, @@ -193,7 +193,7 @@ def test_500(self): SpanAttributes.NET_HOST_PORT: 80, SpanAttributes.HTTP_HOST: "falconframework.org", SpanAttributes.HTTP_TARGET: "/", - SpanAttributes.NET_PEER_PORT: "65133", + SpanAttributes.NET_PEER_PORT: 65133, SpanAttributes.HTTP_FLAVOR: "1.1", SpanAttributes.HTTP_STATUS_CODE: 500, }, @@ -226,7 +226,7 @@ def test_url_template(self): SpanAttributes.NET_HOST_PORT: 80, SpanAttributes.HTTP_HOST: "falconframework.org", SpanAttributes.HTTP_TARGET: "/", - SpanAttributes.NET_PEER_PORT: "65133", + SpanAttributes.NET_PEER_PORT: 65133, SpanAttributes.HTTP_FLAVOR: "1.1", "falcon.resource": "UserResource", SpanAttributes.HTTP_STATUS_CODE: 200, @@ -336,6 +336,7 @@ def test_falcon_metric_values(self): "http.flavor": "1.1", "http.server_name": "falconframework.org", "net.host.port": 80, + "net.host.name": "falconframework.org", "http.status_code": 404, } expected_requests_count_attributes = { @@ -344,6 +345,8 @@ def test_falcon_metric_values(self): "http.scheme": "http", "http.flavor": "1.1", "http.server_name": "falconframework.org", + "net.host.name": "falconframework.org", + "net.host.port": 80, } start = default_timer() self.client().simulate_get("/hello/756") @@ -523,7 +526,7 @@ def test_custom_request_header_not_added_in_internal_span(self): self.assertNotIn(key, span.attributes) @pytest.mark.skipif( - condition=package_version.parse(_falcon_verison) + condition=package_version.parse(_falcon_version) < package_version.parse("2.0.0"), reason="falcon<2 does not implement custom response headers", ) @@ -558,7 +561,7 @@ def test_custom_response_header_added_in_server_span(self): self.assertNotIn(key, span.attributes) @pytest.mark.skipif( - condition=package_version.parse(_falcon_verison) + condition=package_version.parse(_falcon_version) < package_version.parse("2.0.0"), reason="falcon<2 does not implement custom response headers", ) diff --git a/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py b/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py index dec265907f..82ca88460c 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py +++ b/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py @@ -18,17 +18,17 @@ from flask import Flask, request from opentelemetry import trace +from opentelemetry.instrumentation._semconv import ( + _server_active_requests_count_attrs_old, + _server_duration_attrs_old, +) from opentelemetry.instrumentation.flask import FlaskInstrumentor from opentelemetry.instrumentation.propagators import ( TraceResponsePropagator, get_global_response_propagator, set_global_response_propagator, ) -from opentelemetry.instrumentation.wsgi import ( - OpenTelemetryMiddleware, - _active_requests_count_attrs, - _duration_attrs, -) +from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware from opentelemetry.sdk.metrics.export import ( HistogramDataPoint, NumberDataPoint, @@ -54,6 +54,7 @@ def expected_attributes(override_attributes): SpanAttributes.HTTP_SERVER_NAME: "localhost", SpanAttributes.HTTP_SCHEME: "http", SpanAttributes.NET_HOST_PORT: 80, + SpanAttributes.NET_HOST_NAME: "localhost", SpanAttributes.HTTP_HOST: "localhost", SpanAttributes.HTTP_TARGET: "/", SpanAttributes.HTTP_FLAVOR: "1.1", @@ -69,8 +70,8 @@ def expected_attributes(override_attributes): "http.server.duration", ] _recommended_attrs = { - "http.server.active_requests": _active_requests_count_attrs, - "http.server.duration": _duration_attrs, + "http.server.active_requests": _server_active_requests_count_attrs_old, + "http.server.duration": _server_duration_attrs_old, } @@ -358,6 +359,7 @@ def test_basic_metric_success(self): "http.server_name": "localhost", "net.host.port": 80, "http.status_code": 200, + "net.host.name": "localhost", } expected_requests_count_attributes = { "http.method": "GET", @@ -365,6 +367,8 @@ def test_basic_metric_success(self): "http.scheme": "http", "http.flavor": "1.1", "http.server_name": "localhost", + "net.host.name": "localhost", + "net.host.port": 80, } self._assert_basic_metric( expected_duration_attributes, @@ -374,20 +378,23 @@ def test_basic_metric_success(self): def test_basic_metric_nonstandard_http_method_success(self): self.client.open("/hello/756", method="NONSTANDARD") expected_duration_attributes = { - "http.method": "UNKNOWN", + "http.method": "_OTHER", "http.host": "localhost", "http.scheme": "http", "http.flavor": "1.1", "http.server_name": "localhost", "net.host.port": 80, "http.status_code": 405, + "net.host.name": "localhost", } expected_requests_count_attributes = { - "http.method": "UNKNOWN", + "http.method": "_OTHER", "http.host": "localhost", "http.scheme": "http", "http.flavor": "1.1", "http.server_name": "localhost", + "net.host.name": "localhost", + "net.host.port": 80, } self._assert_basic_metric( expected_duration_attributes, @@ -410,6 +417,7 @@ def test_basic_metric_nonstandard_http_method_allowed_success(self): "http.server_name": "localhost", "net.host.port": 80, "http.status_code": 405, + "net.host.name": "localhost", } expected_requests_count_attributes = { "http.method": "NONSTANDARD", @@ -417,6 +425,8 @@ def test_basic_metric_nonstandard_http_method_allowed_success(self): "http.scheme": "http", "http.flavor": "1.1", "http.server_name": "localhost", + "net.host.name": "localhost", + "net.host.port": 80, } self._assert_basic_metric( expected_duration_attributes, diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py b/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py index 2c3ec85e18..4715e0b461 100644 --- a/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py +++ b/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py @@ -18,6 +18,10 @@ from pyramid.config import Configurator from opentelemetry import trace +from opentelemetry.instrumentation._semconv import ( + _server_active_requests_count_attrs_old, + _server_duration_attrs_old, +) from opentelemetry.instrumentation.pyramid import PyramidInstrumentor from opentelemetry.sdk.metrics.export import ( HistogramDataPoint, @@ -31,8 +35,6 @@ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS, OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST, OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE, - _active_requests_count_attrs, - _duration_attrs, ) # pylint: disable=import-error @@ -43,8 +45,8 @@ "http.server.duration", ] _recommended_attrs = { - "http.server.active_requests": _active_requests_count_attrs, - "http.server.duration": _duration_attrs, + "http.server.active_requests": _server_active_requests_count_attrs_old, + "http.server.duration": _server_duration_attrs_old, } @@ -213,6 +215,7 @@ def test_basic_metric_success(self): "http.server_name": "localhost", "net.host.port": 80, "http.status_code": 200, + "net.host.name": "localhost", } expected_requests_count_attributes = { "http.method": "GET", @@ -220,6 +223,8 @@ def test_basic_metric_success(self): "http.scheme": "http", "http.flavor": "1.1", "http.server_name": "localhost", + "net.host.name": "localhost", + "net.host.port": 80, } metrics_list = self.memory_metrics_reader.get_metrics_data() for metric in ( diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_programmatic.py b/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_programmatic.py index 478eab1937..c566c301d8 100644 --- a/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_programmatic.py +++ b/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_programmatic.py @@ -37,6 +37,7 @@ def expected_attributes(override_attributes): SpanAttributes.HTTP_SERVER_NAME: "localhost", SpanAttributes.HTTP_SCHEME: "http", SpanAttributes.NET_HOST_PORT: 80, + SpanAttributes.NET_HOST_NAME: "localhost", SpanAttributes.HTTP_HOST: "localhost", SpanAttributes.HTTP_TARGET: "/", SpanAttributes.HTTP_FLAVOR: "1.1", diff --git a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py index d0150d57b7..2052fe47cd 100644 --- a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py @@ -63,18 +63,20 @@ _SPAN_ATTRIBUTES_ERROR_TYPE, _SPAN_ATTRIBUTES_NETWORK_PEER_ADDRESS, _SPAN_ATTRIBUTES_NETWORK_PEER_PORT, - _filter_duration_attrs, + _client_duration_attrs_new, + _client_duration_attrs_old, + _filter_semconv_duration_attrs, _get_schema_url, + _HTTPStabilityMode, _OpenTelemetrySemanticConventionStability, - _OpenTelemetryStabilityMode, _OpenTelemetryStabilitySignalType, _report_new, _report_old, - _set_http_hostname, + _set_http_host, _set_http_method, - _set_http_net_peer_name, + _set_http_net_peer_name_client, _set_http_network_protocol_version, - _set_http_port, + _set_http_peer_port_client, _set_http_scheme, _set_http_status_code, _set_http_url, @@ -117,7 +119,7 @@ def _instrument( request_hook: _RequestHookT = None, response_hook: _ResponseHookT = None, excluded_urls: ExcludeList = None, - sem_conv_opt_in_mode: _OpenTelemetryStabilityMode = _OpenTelemetryStabilityMode.DEFAULT, + sem_conv_opt_in_mode: _HTTPStabilityMode = _HTTPStabilityMode.DEFAULT, ): """Enables tracing of all requests calls that go through :code:`requests.session.Session.request` (this includes @@ -174,14 +176,14 @@ def get_or_create_headers(): metric_labels, parsed_url.scheme, sem_conv_opt_in_mode ) if parsed_url.hostname: - _set_http_hostname( + _set_http_host( metric_labels, parsed_url.hostname, sem_conv_opt_in_mode ) - _set_http_net_peer_name( + _set_http_net_peer_name_client( metric_labels, parsed_url.hostname, sem_conv_opt_in_mode ) if _report_new(sem_conv_opt_in_mode): - _set_http_hostname( + _set_http_host( span_attributes, parsed_url.hostname, sem_conv_opt_in_mode, @@ -191,11 +193,11 @@ def get_or_create_headers(): _SPAN_ATTRIBUTES_NETWORK_PEER_ADDRESS ] = parsed_url.hostname if parsed_url.port: - _set_http_port( + _set_http_peer_port_client( metric_labels, parsed_url.port, sem_conv_opt_in_mode ) if _report_new(sem_conv_opt_in_mode): - _set_http_port( + _set_http_peer_port_client( span_attributes, parsed_url.port, sem_conv_opt_in_mode ) # Use semconv library when available @@ -284,16 +286,22 @@ def get_or_create_headers(): ).__qualname__ if duration_histogram_old is not None: - duration_attrs_old = _filter_duration_attrs( - metric_labels, _OpenTelemetryStabilityMode.DEFAULT + duration_attrs_old = _filter_semconv_duration_attrs( + metric_labels, + _client_duration_attrs_old, + _client_duration_attrs_new, + _HTTPStabilityMode.DEFAULT, ) duration_histogram_old.record( max(round(elapsed_time * 1000), 0), attributes=duration_attrs_old, ) if duration_histogram_new is not None: - duration_attrs_new = _filter_duration_attrs( - metric_labels, _OpenTelemetryStabilityMode.HTTP + duration_attrs_new = _filter_semconv_duration_attrs( + metric_labels, + _client_duration_attrs_old, + _client_duration_attrs_new, + _HTTPStabilityMode.HTTP, ) duration_histogram_new.record( elapsed_time, attributes=duration_attrs_new @@ -341,7 +349,10 @@ def get_default_span_name(method): Returns: span name """ - return sanitize_method(method.upper().strip()) + method = sanitize_method(method.upper().strip()) + if method == "_OTHER": + return "HTTP" + return method class RequestsInstrumentor(BaseInstrumentor): diff --git a/instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py b/instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py index 8817053068..d85d70e20e 100644 --- a/instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py +++ b/instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py @@ -23,10 +23,10 @@ import opentelemetry.instrumentation.requests from opentelemetry import trace from opentelemetry.instrumentation._semconv import ( - _OTEL_SEMCONV_STABILITY_OPT_IN_KEY, _SPAN_ATTRIBUTES_ERROR_TYPE, _SPAN_ATTRIBUTES_NETWORK_PEER_ADDRESS, _SPAN_ATTRIBUTES_NETWORK_PEER_PORT, + OTEL_SEMCONV_STABILITY_OPT_IN, _OpenTelemetrySemanticConventionStability, ) from opentelemetry.instrumentation.requests import RequestsInstrumentor @@ -88,7 +88,7 @@ def setUp(self): "os.environ", { "OTEL_PYTHON_REQUESTS_EXCLUDED_URLS": "http://localhost/env_excluded_arg/123,env_excluded_noarg", - _OTEL_SEMCONV_STABILITY_OPT_IN_KEY: sem_conv_mode, + OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode, }, ) @@ -524,7 +524,6 @@ def test_requests_exception_new_semconv(self, *_, **__): self.perform_request(url_with_port) span = self.assert_span() - print(span.attributes) self.assertEqual( span.attributes, { @@ -671,7 +670,7 @@ def setUp(self): self.env_patch = mock.patch.dict( "os.environ", { - _OTEL_SEMCONV_STABILITY_OPT_IN_KEY: sem_conv_mode, + OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode, }, ) self.env_patch.start() 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 50d4f03dff..0a873d0fc3 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py @@ -213,10 +213,34 @@ def response_hook(span: Span, environ: WSGIEnvironment, status: str, response_he from timeit import default_timer from opentelemetry import context, trace -from opentelemetry.instrumentation.utils import ( - _start_internal_or_server_span, - http_status_to_status_code, +from opentelemetry.instrumentation._semconv import ( + _METRIC_ATTRIBUTES_SERVER_DURATION_NAME, + _SPAN_ATTRIBUTES_ERROR_TYPE, + _filter_semconv_active_request_count_attr, + _filter_semconv_duration_attrs, + _get_schema_url, + _HTTPStabilityMode, + _OpenTelemetrySemanticConventionStability, + _OpenTelemetryStabilitySignalType, + _report_new, + _report_old, + _server_active_requests_count_attrs_new, + _server_active_requests_count_attrs_old, + _server_duration_attrs_new, + _server_duration_attrs_old, + _set_http_flavor_version, + _set_http_method, + _set_http_net_host, + _set_http_net_host_port, + _set_http_net_peer_name_server, + _set_http_peer_ip, + _set_http_peer_port_server, + _set_http_scheme, + _set_http_target, + _set_http_user_agent, + _set_status, ) +from opentelemetry.instrumentation.utils import _start_internal_or_server_span from opentelemetry.instrumentation.wsgi.version import __version__ from opentelemetry.metrics import get_meter from opentelemetry.propagators.textmap import Getter @@ -228,6 +252,7 @@ def response_hook(span: Span, environ: WSGIEnvironment, status: str, response_he OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST, OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE, SanitizeValue, + _parse_url_query, get_custom_headers, normalise_request_header_name, normalise_response_header_name, @@ -239,26 +264,6 @@ def response_hook(span: Span, environ: WSGIEnvironment, status: str, response_he _CARRIER_KEY_PREFIX = "HTTP_" _CARRIER_KEY_PREFIX_LEN = len(_CARRIER_KEY_PREFIX) -# List of recommended attributes -_duration_attrs = [ - SpanAttributes.HTTP_METHOD, - SpanAttributes.HTTP_HOST, - SpanAttributes.HTTP_SCHEME, - SpanAttributes.HTTP_STATUS_CODE, - SpanAttributes.HTTP_FLAVOR, - SpanAttributes.HTTP_SERVER_NAME, - SpanAttributes.NET_HOST_NAME, - SpanAttributes.NET_HOST_PORT, -] - -_active_requests_count_attrs = [ - SpanAttributes.HTTP_METHOD, - SpanAttributes.HTTP_HOST, - SpanAttributes.HTTP_SCHEME, - SpanAttributes.HTTP_FLAVOR, - SpanAttributes.HTTP_SERVER_NAME, -] - class WSGIGetter(Getter[dict]): def get( @@ -296,53 +301,84 @@ def setifnotnone(dic, key, value): dic[key] = value -def collect_request_attributes(environ): +# pylint: disable=too-many-branches + + +def collect_request_attributes( + environ, + sem_conv_opt_in_mode=_HTTPStabilityMode.DEFAULT, +): """Collects HTTP request attributes from the PEP3333-conforming WSGI environ and returns a dictionary to be used as span creation attributes. """ + result = {} + _set_http_method( + result, + environ.get("REQUEST_METHOD", ""), + sanitize_method(environ.get("REQUEST_METHOD", "")), + sem_conv_opt_in_mode, + ) + # old semconv v1.12.0 + server_name = environ.get("SERVER_NAME") + if _report_old(sem_conv_opt_in_mode): + result[SpanAttributes.HTTP_SERVER_NAME] = server_name + + _set_http_scheme( + result, + environ.get("wsgi.url_scheme"), + sem_conv_opt_in_mode, + ) - result = { - SpanAttributes.HTTP_METHOD: sanitize_method( - environ.get("REQUEST_METHOD") - ), - SpanAttributes.HTTP_SERVER_NAME: environ.get("SERVER_NAME"), - SpanAttributes.HTTP_SCHEME: environ.get("wsgi.url_scheme"), - } - + host = environ.get("HTTP_HOST") host_port = environ.get("SERVER_PORT") - if host_port is not None and not host_port == "": - result.update({SpanAttributes.NET_HOST_PORT: int(host_port)}) + if host: + _set_http_net_host(result, host, sem_conv_opt_in_mode) + # old semconv v1.12.0 + if _report_old(sem_conv_opt_in_mode): + result[SpanAttributes.HTTP_HOST] = host + if host_port: + _set_http_net_host_port( + result, + int(host_port), + sem_conv_opt_in_mode, + ) - setifnotnone(result, SpanAttributes.HTTP_HOST, environ.get("HTTP_HOST")) target = environ.get("RAW_URI") if target is None: # Note: `"" or None is None` target = environ.get("REQUEST_URI") - if target is not None: - result[SpanAttributes.HTTP_TARGET] = target + if target: + path, query = _parse_url_query(target) + _set_http_target(result, target, path, query, sem_conv_opt_in_mode) else: - result[SpanAttributes.HTTP_URL] = remove_url_credentials( - wsgiref_util.request_uri(environ) - ) + # old semconv v1.20.0 + if _report_old(sem_conv_opt_in_mode): + result[SpanAttributes.HTTP_URL] = remove_url_credentials( + wsgiref_util.request_uri(environ) + ) remote_addr = environ.get("REMOTE_ADDR") if remote_addr: - result[SpanAttributes.NET_PEER_IP] = remote_addr + _set_http_peer_ip(result, remote_addr, sem_conv_opt_in_mode) + + peer_port = environ.get("REMOTE_PORT") + if peer_port: + _set_http_peer_port_server(result, peer_port, sem_conv_opt_in_mode) + remote_host = environ.get("REMOTE_HOST") if remote_host and remote_host != remote_addr: - result[SpanAttributes.NET_PEER_NAME] = remote_host + _set_http_net_peer_name_server( + result, remote_host, sem_conv_opt_in_mode + ) user_agent = environ.get("HTTP_USER_AGENT") if user_agent is not None and len(user_agent) > 0: - result[SpanAttributes.HTTP_USER_AGENT] = user_agent + _set_http_user_agent(result, user_agent, sem_conv_opt_in_mode) - setifnotnone( - result, SpanAttributes.NET_PEER_PORT, environ.get("REMOTE_PORT") - ) flavor = environ.get("SERVER_PROTOCOL", "") if flavor.upper().startswith(_HTTP_VERSION_PREFIX): flavor = flavor[len(_HTTP_VERSION_PREFIX) :] if flavor: - result[SpanAttributes.HTTP_FLAVOR] = flavor + _set_http_flavor_version(result, flavor, sem_conv_opt_in_mode) return result @@ -410,46 +446,56 @@ def _parse_status_code(resp_status): return None -def _parse_active_request_count_attrs(req_attrs): - active_requests_count_attrs = {} - for attr_key in _active_requests_count_attrs: - if req_attrs.get(attr_key) is not None: - active_requests_count_attrs[attr_key] = req_attrs[attr_key] - return active_requests_count_attrs +def _parse_active_request_count_attrs( + req_attrs, sem_conv_opt_in_mode=_HTTPStabilityMode.DEFAULT +): + return _filter_semconv_active_request_count_attr( + req_attrs, + _server_active_requests_count_attrs_old, + _server_active_requests_count_attrs_new, + sem_conv_opt_in_mode, + ) -def _parse_duration_attrs(req_attrs): - duration_attrs = {} - for attr_key in _duration_attrs: - if req_attrs.get(attr_key) is not None: - duration_attrs[attr_key] = req_attrs[attr_key] - return duration_attrs +def _parse_duration_attrs( + req_attrs, sem_conv_opt_in_mode=_HTTPStabilityMode.DEFAULT +): + return _filter_semconv_duration_attrs( + req_attrs, + _server_duration_attrs_old, + _server_duration_attrs_new, + sem_conv_opt_in_mode, + ) def add_response_attributes( - span, start_response_status, response_headers + span, + start_response_status, + response_headers, + duration_attrs=None, + sem_conv_opt_in_mode=_HTTPStabilityMode.DEFAULT, ): # pylint: disable=unused-argument """Adds HTTP response attributes to span using the arguments passed to a PEP3333-conforming start_response callable. """ if not span.is_recording(): return - status_code, _ = start_response_status.split(" ", 1) + status_code_str, _ = start_response_status.split(" ", 1) + status_code = 0 try: - status_code = int(status_code) + status_code = int(status_code_str) except ValueError: - span.set_status( - Status( - StatusCode.ERROR, - "Non-integer HTTP status: " + repr(status_code), - ) - ) - else: - span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code) - span.set_status( - Status(http_status_to_status_code(status_code, server_span=True)) - ) + status_code = -1 + if duration_attrs is None: + duration_attrs = {} + _set_status( + span, + duration_attrs, + status_code_str, + status_code, + sem_conv_opt_in_mode, + ) def get_default_span_name(environ): @@ -464,6 +510,8 @@ def get_default_span_name(environ): The span name. """ method = sanitize_method(environ.get("REQUEST_METHOD", "").strip()) + if method == "_OTHER": + return "HTTP" path = environ.get("PATH_INFO", "").strip() if method and path: return f"{method} {path}" @@ -495,42 +543,66 @@ def __init__( tracer_provider=None, meter_provider=None, ): + # initialize semantic conventions opt-in if needed + _OpenTelemetrySemanticConventionStability._initialize() + sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP, + ) self.wsgi = wsgi self.tracer = trace.get_tracer( __name__, __version__, tracer_provider, - schema_url="https://opentelemetry.io/schemas/1.11.0", + schema_url=_get_schema_url(sem_conv_opt_in_mode), ) self.meter = get_meter( __name__, __version__, meter_provider, - schema_url="https://opentelemetry.io/schemas/1.11.0", - ) - self.duration_histogram = self.meter.create_histogram( - name=MetricInstruments.HTTP_SERVER_DURATION, - unit="ms", - description="Duration of HTTP client requests.", + schema_url=_get_schema_url(sem_conv_opt_in_mode), ) + self.duration_histogram_old = None + if _report_old(sem_conv_opt_in_mode): + self.duration_histogram_old = self.meter.create_histogram( + name=MetricInstruments.HTTP_SERVER_DURATION, + unit="ms", + description="measures the duration of the inbound HTTP request", + ) + self.duration_histogram_new = None + if _report_new(sem_conv_opt_in_mode): + self.duration_histogram_new = self.meter.create_histogram( + name=_METRIC_ATTRIBUTES_SERVER_DURATION_NAME, + unit="s", + description="measures the duration of the inbound HTTP request", + ) + # We don't need a separate active request counter for old/new semantic conventions + # because the new attributes are a subset of the old attributes self.active_requests_counter = self.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.", ) self.request_hook = request_hook self.response_hook = response_hook + self._sem_conv_opt_in_mode = sem_conv_opt_in_mode @staticmethod def _create_start_response( - span, start_response, response_hook, duration_attrs + span, + start_response, + response_hook, + duration_attrs, + sem_conv_opt_in_mode, ): @functools.wraps(start_response) def _start_response(status, response_headers, *args, **kwargs): - add_response_attributes(span, status, response_headers) - status_code = _parse_status_code(status) - if status_code is not None: - duration_attrs[SpanAttributes.HTTP_STATUS_CODE] = status_code + add_response_attributes( + span, + status, + response_headers, + duration_attrs, + sem_conv_opt_in_mode, + ) if span.is_recording() and span.kind == trace.SpanKind.SERVER: custom_attributes = collect_custom_response_headers_attributes( response_headers @@ -551,11 +623,13 @@ def __call__(self, environ, start_response): environ: A WSGI environment. start_response: The WSGI start_response callable. """ - req_attrs = collect_request_attributes(environ) + req_attrs = collect_request_attributes( + environ, self._sem_conv_opt_in_mode + ) active_requests_count_attrs = _parse_active_request_count_attrs( - req_attrs + req_attrs, + self._sem_conv_opt_in_mode, ) - duration_attrs = _parse_duration_attrs(req_attrs) span, token = _start_internal_or_server_span( tracer=self.tracer, @@ -584,20 +658,42 @@ def __call__(self, environ, start_response): try: with trace.use_span(span): start_response = self._create_start_response( - span, start_response, response_hook, duration_attrs + span, + start_response, + response_hook, + req_attrs, + self._sem_conv_opt_in_mode, ) iterable = self.wsgi(environ, start_response) return _end_span_after_iterating(iterable, span, token) except Exception as ex: - if span.is_recording(): + if _report_new(self._sem_conv_opt_in_mode): + req_attrs[_SPAN_ATTRIBUTES_ERROR_TYPE] = type(ex).__qualname__ + if span.is_recording(): + span.set_attribute( + _SPAN_ATTRIBUTES_ERROR_TYPE, type(ex).__qualname__ + ) span.set_status(Status(StatusCode.ERROR, str(ex))) span.end() if token is not None: context.detach(token) raise finally: - duration = max(round((default_timer() - start) * 1000), 0) - self.duration_histogram.record(duration, duration_attrs) + duration_s = default_timer() - start + if self.duration_histogram_old: + duration_attrs_old = _parse_duration_attrs( + req_attrs, _HTTPStabilityMode.DEFAULT + ) + self.duration_histogram_old.record( + max(round(duration_s * 1000), 0), duration_attrs_old + ) + if self.duration_histogram_new: + duration_attrs_new = _parse_duration_attrs( + req_attrs, _HTTPStabilityMode.HTTP + ) + self.duration_histogram_new.record( + max(duration_s, 0), duration_attrs_new + ) self.active_requests_counter.add(-1, active_requests_count_attrs) diff --git a/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py b/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py index f74dd67867..985fbe0571 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +# pylint: disable=too-many-lines + import sys import unittest import wsgiref.util as wsgiref_util @@ -20,6 +22,15 @@ import opentelemetry.instrumentation.wsgi as otel_wsgi from opentelemetry import trace as trace_api +from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _HTTPStabilityMode, + _OpenTelemetrySemanticConventionStability, + _server_active_requests_count_attrs_new, + _server_active_requests_count_attrs_old, + _server_duration_attrs_new, + _server_duration_attrs_old, +) from opentelemetry.sdk.metrics.export import ( HistogramDataPoint, NumberDataPoint, @@ -127,17 +138,58 @@ def wsgi_with_repeat_custom_response_headers(environ, start_response): return [b"*"] -_expected_metric_names = [ +_expected_metric_names_old = [ "http.server.active_requests", "http.server.duration", ] -_recommended_attrs = { - "http.server.active_requests": otel_wsgi._active_requests_count_attrs, - "http.server.duration": otel_wsgi._duration_attrs, +_expected_metric_names_new = [ + "http.server.active_requests", + "http.server.request.duration", +] +_recommended_metrics_attrs_old = { + "http.server.active_requests": _server_active_requests_count_attrs_old, + "http.server.duration": _server_duration_attrs_old, +} +_recommended_metrics_attrs_new = { + "http.server.active_requests": _server_active_requests_count_attrs_new, + "http.server.request.duration": _server_duration_attrs_new, +} +_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 +) +_recommended_metrics_attrs_both = { + "http.server.active_requests": _server_active_requests_count_attrs_both, + "http.server.duration": _server_duration_attrs_old, + "http.server.request.duration": _server_duration_attrs_new, } class TestWsgiApplication(WsgiTestBase): + def setUp(self): + super().setUp() + + test_name = "" + if hasattr(self, "_testMethodName"): + test_name = self._testMethodName + sem_conv_mode = "default" + if "new_semconv" in test_name: + sem_conv_mode = "http" + elif "both_semconv" in test_name: + sem_conv_mode = "http/dup" + self.env_patch = mock.patch.dict( + "os.environ", + { + OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode, + }, + ) + + _OpenTelemetrySemanticConventionStability._initialized = False + + self.env_patch.start() + def validate_response( self, response, @@ -146,6 +198,8 @@ def validate_response( http_method="GET", span_attributes=None, response_headers=None, + old_sem_conv=True, + new_sem_conv=False, ): while True: try: @@ -171,7 +225,8 @@ def validate_response( self.assertEqual(len(span_list), 1) self.assertEqual(span_list[0].name, span_name) self.assertEqual(span_list[0].kind, trace_api.SpanKind.SERVER) - expected_attributes = { + expected_attributes = {} + expected_attributes_old = { SpanAttributes.HTTP_SERVER_NAME: "127.0.0.1", SpanAttributes.HTTP_SCHEME: "http", SpanAttributes.NET_HOST_PORT: 80, @@ -179,10 +234,27 @@ def validate_response( SpanAttributes.HTTP_FLAVOR: "1.0", SpanAttributes.HTTP_URL: "http://127.0.0.1/", SpanAttributes.HTTP_STATUS_CODE: 200, + SpanAttributes.NET_HOST_NAME: "127.0.0.1", } + expected_attributes_new = { + SpanAttributes.SERVER_PORT: 80, + SpanAttributes.SERVER_ADDRESS: "127.0.0.1", + SpanAttributes.NETWORK_PROTOCOL_VERSION: "1.0", + SpanAttributes.HTTP_RESPONSE_STATUS_CODE: 200, + } + if old_sem_conv: + expected_attributes.update(expected_attributes_old) + if new_sem_conv: + expected_attributes.update(expected_attributes_new) + expected_attributes.update(span_attributes or {}) if http_method is not None: - expected_attributes[SpanAttributes.HTTP_METHOD] = http_method + if old_sem_conv: + expected_attributes[SpanAttributes.HTTP_METHOD] = http_method + if new_sem_conv: + expected_attributes[ + SpanAttributes.HTTP_REQUEST_METHOD + ] = http_method self.assertEqual(span_list[0].attributes, expected_attributes) def test_basic_wsgi_call(self): @@ -190,6 +262,16 @@ def test_basic_wsgi_call(self): response = app(self.environ, self.start_response) self.validate_response(response) + def test_basic_wsgi_call_new_semconv(self): + app = otel_wsgi.OpenTelemetryMiddleware(simple_wsgi) + response = app(self.environ, self.start_response) + self.validate_response(response, old_sem_conv=False, new_sem_conv=True) + + def test_basic_wsgi_call_both_semconv(self): + app = otel_wsgi.OpenTelemetryMiddleware(simple_wsgi) + response = app(self.environ, self.start_response) + self.validate_response(response, old_sem_conv=True, new_sem_conv=True) + def test_hooks(self): hook_headers = ( "hook_attr", @@ -283,7 +365,7 @@ def test_wsgi_metrics(self): 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) + 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: @@ -294,7 +376,77 @@ def test_wsgi_metrics(self): number_data_point_seen = True for attr in point.attributes: self.assertIn( - attr, _recommended_attrs[metric.name] + attr, + _recommended_metrics_attrs_old[metric.name], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + + def test_wsgi_metrics_new_semconv(self): + 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) + 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[metric.name], + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + + def test_wsgi_metrics_both_semconv(self): + 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) + 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[metric.name], ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) @@ -303,7 +455,7 @@ def test_nonstandard_http_method(self): app = otel_wsgi.OpenTelemetryMiddleware(simple_wsgi) response = app(self.environ, self.start_response) self.validate_response( - response, span_name="UNKNOWN /", http_method="UNKNOWN" + response, span_name="HTTP", http_method="_OTHER" ) @mock.patch.dict( @@ -349,34 +501,95 @@ def test_request_attributes(self): SpanAttributes.HTTP_SCHEME: "http", SpanAttributes.HTTP_SERVER_NAME: "127.0.0.1", SpanAttributes.HTTP_FLAVOR: "1.0", + SpanAttributes.NET_HOST_NAME: "127.0.0.1", + }, + ) + + def test_request_attributes_new_semconv(self): + self.environ["QUERY_STRING"] = "foo=bar" + self.environ["REQUEST_URI"] = "http://127.0.0.1/?foo=bar" + + attrs = otel_wsgi.collect_request_attributes( + self.environ, + _HTTPStabilityMode.HTTP, + ) + self.assertDictEqual( + attrs, + { + SpanAttributes.HTTP_REQUEST_METHOD: "GET", + SpanAttributes.SERVER_ADDRESS: "127.0.0.1", + SpanAttributes.SERVER_PORT: 80, + SpanAttributes.NETWORK_PROTOCOL_VERSION: "1.0", + SpanAttributes.URL_PATH: "/", + SpanAttributes.URL_QUERY: "foo=bar", }, ) - def validate_url(self, expected_url, raw=False, has_host=True): + def validate_url( + self, + expected_url, + raw=False, + has_host=True, + old_semconv=True, + new_semconv=False, + ): parts = urlsplit(expected_url) - expected = { + expected_old = { SpanAttributes.HTTP_SCHEME: parts.scheme, SpanAttributes.NET_HOST_PORT: parts.port or (80 if parts.scheme == "http" else 443), SpanAttributes.HTTP_SERVER_NAME: parts.hostname, # Not true in the general case, but for all tests. } - if raw: - expected[SpanAttributes.HTTP_TARGET] = expected_url.split( - parts.netloc, 1 - )[1] - else: - expected[SpanAttributes.HTTP_URL] = expected_url - if has_host: - expected[SpanAttributes.HTTP_HOST] = parts.hostname + expected_new = { + SpanAttributes.SERVER_PORT: parts.port + or (80 if parts.scheme == "http" else 443), + SpanAttributes.SERVER_ADDRESS: parts.hostname, + SpanAttributes.URL_PATH: parts.path, + SpanAttributes.URL_QUERY: parts.query, + } + if old_semconv: + if raw: + expected_old[SpanAttributes.HTTP_TARGET] = expected_url.split( + parts.netloc, 1 + )[1] + else: + expected_old[SpanAttributes.HTTP_URL] = expected_url + if has_host: + expected_old[SpanAttributes.HTTP_HOST] = parts.hostname + if new_semconv: + if raw: + expected_new[SpanAttributes.URL_PATH] = expected_url.split( + parts.path, 1 + )[1] + if parts.query: + expected_new[ + SpanAttributes.URL_QUERY + ] = expected_url.split(parts.query, 1)[1] + else: + expected_new[SpanAttributes.HTTP_URL] = expected_url + if has_host: + expected_new[SpanAttributes.SERVER_ADDRESS] = parts.hostname attrs = otel_wsgi.collect_request_attributes(self.environ) self.assertGreaterEqual( - attrs.items(), expected.items(), expected_url + " expected." + attrs.items(), expected_old.items(), expected_url + " expected." ) def test_request_attributes_with_partial_raw_uri(self): - self.environ["RAW_URI"] = "/#top" - self.validate_url("http://127.0.0.1/#top", raw=True) + self.environ["RAW_URI"] = "/?foo=bar/#top" + self.validate_url("http://127.0.0.1/?foo=bar/#top", raw=True) + self.validate_url( + "http://127.0.0.1/?foo=bar/#top", + raw=True, + old_semconv=False, + new_semconv=True, + ) + self.validate_url( + "http://127.0.0.1/?foo=bar/#top", + raw=True, + old_semconv=True, + new_semconv=True, + ) def test_request_attributes_with_partial_raw_uri_and_nonstandard_port( self, @@ -385,18 +598,68 @@ def test_request_attributes_with_partial_raw_uri_and_nonstandard_port( del self.environ["HTTP_HOST"] self.environ["SERVER_PORT"] = "8080" self.validate_url("http://127.0.0.1:8080/?", raw=True, has_host=False) + self.validate_url( + "http://127.0.0.1:8080/?", + raw=True, + has_host=False, + old_semconv=False, + new_semconv=True, + ) + self.validate_url( + "http://127.0.0.1:8080/?", + raw=True, + has_host=False, + old_semconv=True, + new_semconv=True, + ) def test_https_uri_port(self): del self.environ["HTTP_HOST"] self.environ["SERVER_PORT"] = "443" self.environ["wsgi.url_scheme"] = "https" self.validate_url("https://127.0.0.1/", has_host=False) + self.validate_url( + "https://127.0.0.1/", + has_host=False, + old_semconv=False, + new_semconv=True, + ) + self.validate_url( + "https://127.0.0.1/", + has_host=False, + old_semconv=True, + new_semconv=True, + ) self.environ["SERVER_PORT"] = "8080" self.validate_url("https://127.0.0.1:8080/", has_host=False) + self.validate_url( + "https://127.0.0.1:8080/", + has_host=False, + old_semconv=False, + new_semconv=True, + ) + self.validate_url( + "https://127.0.0.1:8080/", + has_host=False, + old_semconv=True, + new_semconv=True, + ) self.environ["SERVER_PORT"] = "80" self.validate_url("https://127.0.0.1:80/", has_host=False) + self.validate_url( + "https://127.0.0.1:80/", + has_host=False, + old_semconv=False, + new_semconv=True, + ) + self.validate_url( + "https://127.0.0.1:80/", + has_host=False, + old_semconv=True, + new_semconv=True, + ) def test_http_uri_port(self): del self.environ["HTTP_HOST"] @@ -438,10 +701,10 @@ def test_request_attributes_with_faux_scheme_relative_raw_uri(self): def test_request_attributes_pathless(self): self.environ["RAW_URI"] = "" - expected = {SpanAttributes.HTTP_TARGET: ""} - self.assertGreaterEqual( - otel_wsgi.collect_request_attributes(self.environ).items(), - expected.items(), + self.assertIsNone( + otel_wsgi.collect_request_attributes(self.environ).get( + SpanAttributes.HTTP_TARGET + ) ) def test_request_attributes_with_full_request_uri(self): @@ -449,29 +712,58 @@ def test_request_attributes_with_full_request_uri(self): self.environ["REQUEST_METHOD"] = "CONNECT" self.environ[ "REQUEST_URI" - ] = "127.0.0.1:8080" # Might happen in a CONNECT request - expected = { + ] = "http://docs.python.org:80/3/library/urllib.parse.html?highlight=params#url-parsing" # Might happen in a CONNECT request + expected_old = { SpanAttributes.HTTP_HOST: "127.0.0.1:8080", - SpanAttributes.HTTP_TARGET: "127.0.0.1:8080", + SpanAttributes.HTTP_TARGET: "http://docs.python.org:80/3/library/urllib.parse.html?highlight=params#url-parsing", + } + expected_new = { + SpanAttributes.URL_PATH: "/3/library/urllib.parse.html", + SpanAttributes.URL_QUERY: "highlight=params", } self.assertGreaterEqual( otel_wsgi.collect_request_attributes(self.environ).items(), - expected.items(), + expected_old.items(), + ) + self.assertGreaterEqual( + otel_wsgi.collect_request_attributes( + self.environ, + _HTTPStabilityMode.HTTP, + ).items(), + expected_new.items(), ) def test_http_user_agent_attribute(self): self.environ["HTTP_USER_AGENT"] = "test-useragent" expected = {SpanAttributes.HTTP_USER_AGENT: "test-useragent"} + expected_new = {SpanAttributes.USER_AGENT_ORIGINAL: "test-useragent"} self.assertGreaterEqual( otel_wsgi.collect_request_attributes(self.environ).items(), expected.items(), ) + self.assertGreaterEqual( + otel_wsgi.collect_request_attributes( + self.environ, + _HTTPStabilityMode.HTTP, + ).items(), + expected_new.items(), + ) def test_response_attributes(self): otel_wsgi.add_response_attributes(self.span, "404 Not Found", {}) + otel_wsgi.add_response_attributes( + self.span, + "404 Not Found", + {}, + sem_conv_opt_in_mode=_HTTPStabilityMode.HTTP, + ) expected = (mock.call(SpanAttributes.HTTP_STATUS_CODE, 404),) - self.assertEqual(self.span.set_attribute.call_count, len(expected)) + expected_new = ( + mock.call(SpanAttributes.HTTP_RESPONSE_STATUS_CODE, 404), + ) + self.assertEqual(self.span.set_attribute.call_count, 2) self.span.set_attribute.assert_has_calls(expected, any_order=True) + self.span.set_attribute.assert_has_calls(expected_new, any_order=True) def test_credential_removal(self): self.environ["HTTP_HOST"] = "username:password@mock" diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py index fbfc92cf21..31c2486acc 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py @@ -16,13 +16,16 @@ import threading from enum import Enum +from opentelemetry.instrumentation.utils import http_status_to_status_code from opentelemetry.semconv.trace import SpanAttributes +from opentelemetry.trace.status import Status, StatusCode # TODO: will come through semconv package once updated _SPAN_ATTRIBUTES_ERROR_TYPE = "error.type" _SPAN_ATTRIBUTES_NETWORK_PEER_ADDRESS = "network.peer.address" _SPAN_ATTRIBUTES_NETWORK_PEER_PORT = "network.peer.port" _METRIC_ATTRIBUTES_CLIENT_DURATION_NAME = "http.client.request.duration" +_METRIC_ATTRIBUTES_SERVER_DURATION_NAME = "http.server.request.duration" _client_duration_attrs_old = [ SpanAttributes.HTTP_STATUS_CODE, @@ -45,13 +48,117 @@ # SpanAttributes.URL_SCHEME, ] +_server_duration_attrs_old = [ + SpanAttributes.HTTP_METHOD, + SpanAttributes.HTTP_HOST, + SpanAttributes.HTTP_SCHEME, + SpanAttributes.HTTP_STATUS_CODE, + SpanAttributes.HTTP_FLAVOR, + SpanAttributes.HTTP_SERVER_NAME, + SpanAttributes.NET_HOST_NAME, + SpanAttributes.NET_HOST_PORT, +] + +_server_duration_attrs_new = [ + _SPAN_ATTRIBUTES_ERROR_TYPE, + SpanAttributes.HTTP_REQUEST_METHOD, + SpanAttributes.HTTP_RESPONSE_STATUS_CODE, + SpanAttributes.HTTP_ROUTE, + SpanAttributes.NETWORK_PROTOCOL_VERSION, + # TODO: Support opt-in for scheme in new semconv + # SpanAttributes.URL_SCHEME, +] + +_server_active_requests_count_attrs_old = [ + SpanAttributes.HTTP_METHOD, + SpanAttributes.HTTP_HOST, + SpanAttributes.HTTP_SCHEME, + SpanAttributes.HTTP_FLAVOR, + SpanAttributes.HTTP_SERVER_NAME, + SpanAttributes.NET_HOST_NAME, + SpanAttributes.NET_HOST_PORT, +] + +_server_active_requests_count_attrs_new = [ + SpanAttributes.HTTP_REQUEST_METHOD, + SpanAttributes.URL_SCHEME, +] + +OTEL_SEMCONV_STABILITY_OPT_IN = "OTEL_SEMCONV_STABILITY_OPT_IN" + + +class _OpenTelemetryStabilitySignalType: + HTTP = "http" + + +class _HTTPStabilityMode(Enum): + # http - emit the new, stable HTTP and networking conventions ONLY + HTTP = "http" + # http/dup - emit both the old and the stable HTTP and networking conventions + HTTP_DUP = "http/dup" + # default - continue emitting old experimental HTTP and networking conventions + DEFAULT = "default" + + +def _report_new(mode): + return mode.name != _HTTPStabilityMode.DEFAULT.name + + +def _report_old(mode): + return mode.name != _HTTPStabilityMode.HTTP.name -def _filter_duration_attrs(attrs, sem_conv_opt_in_mode): + +class _OpenTelemetrySemanticConventionStability: + _initialized = False + _lock = threading.Lock() + _OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING = {} + + @classmethod + def _initialize(cls): + with _OpenTelemetrySemanticConventionStability._lock: + if not _OpenTelemetrySemanticConventionStability._initialized: + # Users can pass in comma delimited string for opt-in options + # Only values for http stability are supported for now + opt_in = os.environ.get(OTEL_SEMCONV_STABILITY_OPT_IN, "") + opt_in_list = [] + if opt_in: + opt_in_list = [s.strip() for s in opt_in.split(",")] + http_opt_in = _HTTPStabilityMode.DEFAULT + if opt_in_list: + # Process http opt-in + # http/dup takes priority over http + if _HTTPStabilityMode.HTTP_DUP.value in opt_in_list: + http_opt_in = _HTTPStabilityMode.HTTP_DUP + elif _HTTPStabilityMode.HTTP.value in opt_in_list: + http_opt_in = _HTTPStabilityMode.HTTP + _OpenTelemetrySemanticConventionStability._OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING[ + _OpenTelemetryStabilitySignalType.HTTP + ] = http_opt_in + _OpenTelemetrySemanticConventionStability._initialized = True + + @classmethod + # Get OpenTelemetry opt-in mode based off of signal type (http, messaging, etc.) + def _get_opentelemetry_stability_opt_in_mode( + cls, + signal_type: _OpenTelemetryStabilitySignalType, + ) -> _HTTPStabilityMode: + return _OpenTelemetrySemanticConventionStability._OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING.get( + signal_type, _HTTPStabilityMode.DEFAULT + ) + + +def _filter_semconv_duration_attrs( + attrs, + old_attrs, + new_attrs, + sem_conv_opt_in_mode=_HTTPStabilityMode.DEFAULT, +): filtered_attrs = {} + # duration is two different metrics depending on sem_conv_opt_in_mode, so no DUP attributes allowed_attributes = ( - _client_duration_attrs_new - if sem_conv_opt_in_mode == _OpenTelemetryStabilityMode.HTTP - else _client_duration_attrs_old + new_attrs + if sem_conv_opt_in_mode == _HTTPStabilityMode.HTTP + else old_attrs ) for key, val in attrs.items(): if key in allowed_attributes: @@ -59,6 +166,24 @@ def _filter_duration_attrs(attrs, sem_conv_opt_in_mode): return filtered_attrs +def _filter_semconv_active_request_count_attr( + attrs, + old_attrs, + new_attrs, + sem_conv_opt_in_mode=_HTTPStabilityMode.DEFAULT, +): + filtered_attrs = {} + if _report_old(sem_conv_opt_in_mode): + for key, val in attrs.items(): + if key in old_attrs: + filtered_attrs[key] = val + if _report_new(sem_conv_opt_in_mode): + for key, val in attrs.items(): + if key in new_attrs: + filtered_attrs[key] = val + return filtered_attrs + + def set_string_attribute(result, key, value): if value: result[key] = value @@ -90,6 +215,15 @@ def _set_http_method(result, original, normalized, sem_conv_opt_in_mode): ) +def _set_http_status_code(result, code, sem_conv_opt_in_mode): + if _report_old(sem_conv_opt_in_mode): + set_int_attribute(result, SpanAttributes.HTTP_STATUS_CODE, code) + if _report_new(sem_conv_opt_in_mode): + set_int_attribute( + result, SpanAttributes.HTTP_RESPONSE_STATUS_CODE, code + ) + + def _set_http_url(result, url, sem_conv_opt_in_mode): if _report_old(sem_conv_opt_in_mode): set_string_attribute(result, SpanAttributes.HTTP_URL, url) @@ -105,36 +239,30 @@ def _set_http_scheme(result, scheme, sem_conv_opt_in_mode): # set_string_attribute(result, SpanAttributes.URL_SCHEME, scheme) -def _set_http_hostname(result, hostname, sem_conv_opt_in_mode): +def _set_http_host(result, host, sem_conv_opt_in_mode): if _report_old(sem_conv_opt_in_mode): - set_string_attribute(result, SpanAttributes.HTTP_HOST, hostname) + set_string_attribute(result, SpanAttributes.HTTP_HOST, host) if _report_new(sem_conv_opt_in_mode): - set_string_attribute(result, SpanAttributes.SERVER_ADDRESS, hostname) + set_string_attribute(result, SpanAttributes.SERVER_ADDRESS, host) + +# Client -def _set_http_net_peer_name(result, peer_name, sem_conv_opt_in_mode): + +def _set_http_net_peer_name_client(result, peer_name, sem_conv_opt_in_mode): if _report_old(sem_conv_opt_in_mode): set_string_attribute(result, SpanAttributes.NET_PEER_NAME, peer_name) if _report_new(sem_conv_opt_in_mode): set_string_attribute(result, SpanAttributes.SERVER_ADDRESS, peer_name) -def _set_http_port(result, port, sem_conv_opt_in_mode): +def _set_http_peer_port_client(result, port, sem_conv_opt_in_mode): if _report_old(sem_conv_opt_in_mode): set_int_attribute(result, SpanAttributes.NET_PEER_PORT, port) if _report_new(sem_conv_opt_in_mode): set_int_attribute(result, SpanAttributes.SERVER_PORT, port) -def _set_http_status_code(result, code, sem_conv_opt_in_mode): - if _report_old(sem_conv_opt_in_mode): - set_int_attribute(result, SpanAttributes.HTTP_STATUS_CODE, code) - if _report_new(sem_conv_opt_in_mode): - set_int_attribute( - result, SpanAttributes.HTTP_RESPONSE_STATUS_CODE, code - ) - - def _set_http_network_protocol_version(result, version, sem_conv_opt_in_mode): if _report_old(sem_conv_opt_in_mode): set_string_attribute(result, SpanAttributes.HTTP_FLAVOR, version) @@ -144,74 +272,117 @@ def _set_http_network_protocol_version(result, version, sem_conv_opt_in_mode): ) -_OTEL_SEMCONV_STABILITY_OPT_IN_KEY = "OTEL_SEMCONV_STABILITY_OPT_IN" +# Server -class _OpenTelemetryStabilitySignalType: - HTTP = "http" +def _set_http_net_host(result, host, sem_conv_opt_in_mode): + if _report_old(sem_conv_opt_in_mode): + set_string_attribute(result, SpanAttributes.NET_HOST_NAME, host) + if _report_new(sem_conv_opt_in_mode): + set_string_attribute(result, SpanAttributes.SERVER_ADDRESS, host) -class _OpenTelemetryStabilityMode(Enum): - # http - emit the new, stable HTTP and networking conventions ONLY - HTTP = "http" - # http/dup - emit both the old and the stable HTTP and networking conventions - HTTP_DUP = "http/dup" - # default - continue emitting old experimental HTTP and networking conventions - DEFAULT = "default" +def _set_http_net_host_port(result, port, sem_conv_opt_in_mode): + if _report_old(sem_conv_opt_in_mode): + set_int_attribute(result, SpanAttributes.NET_HOST_PORT, port) + if _report_new(sem_conv_opt_in_mode): + set_int_attribute(result, SpanAttributes.SERVER_PORT, port) -def _report_new(mode): - return mode.name != _OpenTelemetryStabilityMode.DEFAULT.name +def _set_http_target(result, target, path, query, sem_conv_opt_in_mode): + if _report_old(sem_conv_opt_in_mode): + set_string_attribute(result, SpanAttributes.HTTP_TARGET, target) + if _report_new(sem_conv_opt_in_mode): + if path: + set_string_attribute(result, SpanAttributes.URL_PATH, path) + if query: + set_string_attribute(result, SpanAttributes.URL_QUERY, query) -def _report_old(mode): - return mode.name != _OpenTelemetryStabilityMode.HTTP.name +def _set_http_peer_ip(result, ip, sem_conv_opt_in_mode): + if _report_old(sem_conv_opt_in_mode): + set_string_attribute(result, SpanAttributes.NET_PEER_IP, ip) + if _report_new(sem_conv_opt_in_mode): + set_string_attribute(result, SpanAttributes.CLIENT_ADDRESS, ip) -class _OpenTelemetrySemanticConventionStability: - _initialized = False - _lock = threading.Lock() - _OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING = {} +def _set_http_peer_port_server(result, port, sem_conv_opt_in_mode): + if _report_old(sem_conv_opt_in_mode): + set_int_attribute(result, SpanAttributes.NET_PEER_PORT, port) + if _report_new(sem_conv_opt_in_mode): + set_int_attribute(result, SpanAttributes.CLIENT_PORT, port) - @classmethod - def _initialize(cls): - with _OpenTelemetrySemanticConventionStability._lock: - if not _OpenTelemetrySemanticConventionStability._initialized: - # Users can pass in comma delimited string for opt-in options - # Only values for http stability are supported for now - opt_in = os.environ.get(_OTEL_SEMCONV_STABILITY_OPT_IN_KEY, "") - opt_in_list = [] - if opt_in: - opt_in_list = [s.strip() for s in opt_in.split(",")] - http_opt_in = _OpenTelemetryStabilityMode.DEFAULT - if opt_in_list: - # Process http opt-in - # http/dup takes priority over http - if ( - _OpenTelemetryStabilityMode.HTTP_DUP.value - in opt_in_list - ): - http_opt_in = _OpenTelemetryStabilityMode.HTTP_DUP - elif _OpenTelemetryStabilityMode.HTTP.value in opt_in_list: - http_opt_in = _OpenTelemetryStabilityMode.HTTP - _OpenTelemetrySemanticConventionStability._OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING[ - _OpenTelemetryStabilitySignalType.HTTP - ] = http_opt_in - _OpenTelemetrySemanticConventionStability._initialized = True - @classmethod - # Get OpenTelemetry opt-in mode based off of signal type (http, messaging, etc.) - def _get_opentelemetry_stability_opt_in_mode( - cls, - signal_type: _OpenTelemetryStabilitySignalType, - ) -> _OpenTelemetryStabilityMode: - return _OpenTelemetrySemanticConventionStability._OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING.get( - signal_type, _OpenTelemetryStabilityMode.DEFAULT +def _set_http_user_agent(result, user_agent, sem_conv_opt_in_mode): + if _report_old(sem_conv_opt_in_mode): + set_string_attribute( + result, SpanAttributes.HTTP_USER_AGENT, user_agent + ) + if _report_new(sem_conv_opt_in_mode): + set_string_attribute( + result, SpanAttributes.USER_AGENT_ORIGINAL, user_agent + ) + + +def _set_http_net_peer_name_server(result, name, sem_conv_opt_in_mode): + if _report_old(sem_conv_opt_in_mode): + set_string_attribute(result, SpanAttributes.NET_PEER_NAME, name) + if _report_new(sem_conv_opt_in_mode): + set_string_attribute(result, SpanAttributes.CLIENT_ADDRESS, name) + + +def _set_http_flavor_version(result, version, sem_conv_opt_in_mode): + if _report_old(sem_conv_opt_in_mode): + set_string_attribute(result, SpanAttributes.HTTP_FLAVOR, version) + if _report_new(sem_conv_opt_in_mode): + set_string_attribute( + result, SpanAttributes.NETWORK_PROTOCOL_VERSION, version + ) + + +def _set_status( + span, + metrics_attributes, + status_code_str, + status_code, + sem_conv_opt_in_mode, +): + if status_code < 0: + if _report_new(sem_conv_opt_in_mode): + span.set_attribute(_SPAN_ATTRIBUTES_ERROR_TYPE, status_code_str) + metrics_attributes[_SPAN_ATTRIBUTES_ERROR_TYPE] = status_code_str + + span.set_status( + Status( + StatusCode.ERROR, + "Non-integer HTTP status: " + status_code_str, + ) ) + else: + status = http_status_to_status_code(status_code, server_span=True) + + if _report_old(sem_conv_opt_in_mode): + span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code) + metrics_attributes[SpanAttributes.HTTP_STATUS_CODE] = status_code + if _report_new(sem_conv_opt_in_mode): + span.set_attribute( + SpanAttributes.HTTP_RESPONSE_STATUS_CODE, status_code + ) + metrics_attributes[ + SpanAttributes.HTTP_RESPONSE_STATUS_CODE + ] = status_code + if status == StatusCode.ERROR: + span.set_attribute( + _SPAN_ATTRIBUTES_ERROR_TYPE, status_code_str + ) + metrics_attributes[ + _SPAN_ATTRIBUTES_ERROR_TYPE + ] = status_code_str + span.set_status(Status(status)) # Get schema version based off of opt-in mode -def _get_schema_url(mode: _OpenTelemetryStabilityMode) -> str: - if mode is _OpenTelemetryStabilityMode.DEFAULT: +def _get_schema_url(mode: _HTTPStabilityMode) -> str: + if mode is _HTTPStabilityMode.DEFAULT: return "https://opentelemetry.io/schemas/1.11.0" return SpanAttributes.SCHEMA_URL diff --git a/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py b/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py index 523f9400b1..1f7ce98937 100644 --- a/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py +++ b/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py @@ -218,7 +218,7 @@ def sanitize_method(method: Optional[str]) -> Optional[str]: ] ): return method - return "UNKNOWN" + return "_OTHER" def get_custom_headers(env_var: str) -> list[str]: @@ -245,3 +245,10 @@ def _parse_duration_attrs(req_attrs): for key in _duration_attrs.intersection(req_attrs.keys()) } return duration_attrs + + +def _parse_url_query(url: str): + parsed_url = urlparse(url) + path = parsed_url.path + query_params = parsed_url.query + return path, query_params