diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd9235c09..8e12ff8586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- `opentelemetry-instrumentation-django` Handle exceptions from request/response hooks - ([#2153](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2153)) -- `opentelemetry-instrumentation-asyncio` instrumented `asyncio.wait_for` properly raises `asyncio.TimeoutError` as expected - ([#2637](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2637)) -- `opentelemetry-instrumentation-aws-lambda` Bugfix: AWS Lambda event source key incorrect for SNS in instrumentation library. - ([#2612](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2612)) -- `opentelemetry-instrumentation-system-metrics` Permit to use psutil 6.0+. - ([#2630](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2630)) -- `opentelemetry-instrumentation-asgi` Fix generation of `http.target` and `http.url` attributes for ASGI apps - using sub apps - ([#2477](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2477)) - - ### Added - `opentelemetry-instrumentation-pyramid` Record exceptions raised when serving a request @@ -30,11 +17,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#2616](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2616)) - `opentelemetry-instrumentation-confluent-kafka` Add support for produce purge ([#2638](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2638)) +- `opentelemetry-instrumentation-system-metrics` Permit to use psutil 6.0+. + ([#2630](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2630)) ### Breaking changes - `opentelemetry-instrumentation-asgi`, `opentelemetry-instrumentation-fastapi`, `opentelemetry-instrumentation-starlette` Use `tracer` and `meter` of originating components instead of one from `asgi` middleware ([#2580](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2580)) +- Populate `{method}` as `HTTP` on `_OTHER` methods from scope + ([#2610](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2610)) + +### Added + +- `opentelemetry-instrumentation-asgi` Implement new semantic convention opt-in with stable http semantic conventions + ([#2610](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2610)) ### Fixed @@ -50,6 +46,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#2644](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2644)) - `opentelemetry-instrumentation-confluent-kafka` Confluent Kafka: Ensure consume span is ended when consumer is closed ([#2640](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2640)) +- `opentelemetry-instrumentation-asgi` Fix generation of `http.target` and `http.url` attributes for ASGI apps + using sub apps + ([#2477](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2477)) +- `opentelemetry-instrumentation-aws-lambda` Bugfix: AWS Lambda event source key incorrect for SNS in instrumentation library. + ([#2612](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2612)) +- `opentelemetry-instrumentation-asyncio` instrumented `asyncio.wait_for` properly raises `asyncio.TimeoutError` as expected + ([#2637](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2637)) +- `opentelemetry-instrumentation-django` Handle exceptions from request/response hooks + ([#2153](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2153)) +- `opentelemetry-instrumentation-asgi` Removed `NET_HOST_NAME` AND `NET_HOST_PORT` from active requests count attribute + ([#2610](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2610)) + ## Version 1.25.0/0.46b0 (2024-05-31) diff --git a/instrumentation/README.md b/instrumentation/README.md index 682334db1a..26c7d24daf 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -5,7 +5,7 @@ | [opentelemetry-instrumentation-aiohttp-client](./opentelemetry-instrumentation-aiohttp-client) | aiohttp ~= 3.0 | No | experimental | [opentelemetry-instrumentation-aiohttp-server](./opentelemetry-instrumentation-aiohttp-server) | aiohttp ~= 3.0 | No | experimental | [opentelemetry-instrumentation-aiopg](./opentelemetry-instrumentation-aiopg) | aiopg >= 0.13.0, < 2.0.0 | No | experimental -| [opentelemetry-instrumentation-asgi](./opentelemetry-instrumentation-asgi) | asgiref ~= 3.0 | No | experimental +| [opentelemetry-instrumentation-asgi](./opentelemetry-instrumentation-asgi) | asgiref ~= 3.0 | Yes | migration | [opentelemetry-instrumentation-asyncio](./opentelemetry-instrumentation-asyncio) | asyncio | No | experimental | [opentelemetry-instrumentation-asyncpg](./opentelemetry-instrumentation-asyncpg) | asyncpg >= 0.12.0 | No | experimental | [opentelemetry-instrumentation-aws-lambda](./opentelemetry-instrumentation-aws-lambda) | aws_lambda | No | experimental 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 b3cd9025c7..f006f9b0c9 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py @@ -201,6 +201,31 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A from asgiref.compatibility import guarantee_single_callable from opentelemetry import context, trace +from opentelemetry.instrumentation._semconv import ( + _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_host, + _set_http_method, + _set_http_net_host_port, + _set_http_peer_ip, + _set_http_peer_port_server, + _set_http_scheme, + _set_http_target, + _set_http_url, + _set_http_user_agent, + _set_status, +) from opentelemetry.instrumentation.asgi.types import ( ClientRequestHook, ClientResponseHook, @@ -210,27 +235,31 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A from opentelemetry.instrumentation.propagators import ( get_global_response_propagator, ) -from opentelemetry.instrumentation.utils import ( - _start_internal_or_server_span, - http_status_to_status_code, -) +from opentelemetry.instrumentation.utils import _start_internal_or_server_span from opentelemetry.metrics import get_meter from opentelemetry.propagators.textmap import Getter, Setter +from opentelemetry.semconv._incubating.metrics.http_metrics import ( + create_http_server_active_requests, + create_http_server_request_body_size, + create_http_server_response_body_size, +) 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 import set_span_in_context -from opentelemetry.trace.status import Status, StatusCode from opentelemetry.util.http import ( OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS, OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST, OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE, SanitizeValue, - _parse_active_request_count_attrs, - _parse_duration_attrs, + _parse_url_query, get_custom_headers, normalise_request_header_name, normalise_response_header_name, remove_url_credentials, + sanitize_method, ) @@ -295,7 +324,10 @@ def set( asgi_setter = ASGISetter() -def collect_request_attributes(scope): +# pylint: disable=too-many-branches +def collect_request_attributes( + scope, sem_conv_opt_in_mode=_HTTPStabilityMode.DEFAULT +): """Collects HTTP request attributes from the ASGI scope and returns a dictionary to be used as span creation attributes.""" server_host, port, http_url = get_host_port_url_tuple(scope) @@ -304,31 +336,52 @@ def collect_request_attributes(scope): if isinstance(query_string, bytes): query_string = query_string.decode("utf8") http_url += "?" + urllib.parse.unquote(query_string) + result = {} + + scheme = scope.get("scheme") + if scheme: + _set_http_scheme(result, scheme, sem_conv_opt_in_mode) + if server_host: + _set_http_host(result, server_host, sem_conv_opt_in_mode) + if port: + _set_http_net_host_port(result, port, sem_conv_opt_in_mode) + flavor = scope.get("http_version") + if flavor: + _set_http_flavor_version(result, flavor, sem_conv_opt_in_mode) + path = scope.get("path") + if path: + _set_http_target( + result, path, path, query_string, sem_conv_opt_in_mode + ) + if http_url: + _set_http_url( + result, remove_url_credentials(http_url), sem_conv_opt_in_mode + ) - result = { - SpanAttributes.HTTP_SCHEME: scope.get("scheme"), - SpanAttributes.HTTP_HOST: server_host, - SpanAttributes.NET_HOST_PORT: port, - SpanAttributes.HTTP_FLAVOR: scope.get("http_version"), - SpanAttributes.HTTP_TARGET: scope.get("path"), - SpanAttributes.HTTP_URL: remove_url_credentials(http_url), - } - http_method = scope.get("method") + http_method = scope.get("method", "") if http_method: - result[SpanAttributes.HTTP_METHOD] = http_method + _set_http_method( + result, + http_method, + sanitize_method(http_method), + sem_conv_opt_in_mode, + ) http_host_value_list = asgi_getter.get(scope, "host") if http_host_value_list: - result[SpanAttributes.HTTP_SERVER_NAME] = ",".join( - http_host_value_list - ) + if _report_old(sem_conv_opt_in_mode): + result[SpanAttributes.HTTP_SERVER_NAME] = ",".join( + http_host_value_list + ) http_user_agent = asgi_getter.get(scope, "user-agent") if http_user_agent: - result[SpanAttributes.HTTP_USER_AGENT] = http_user_agent[0] + _set_http_user_agent(result, http_user_agent[0], sem_conv_opt_in_mode) if "client" in scope and scope["client"] is not None: - result[SpanAttributes.NET_PEER_IP] = scope.get("client")[0] - result[SpanAttributes.NET_PEER_PORT] = scope.get("client")[1] + _set_http_peer_ip(result, scope.get("client")[0], sem_conv_opt_in_mode) + _set_http_peer_port_server( + result, scope.get("client")[1], sem_conv_opt_in_mode + ) # remove None values result = {k: v for k, v in result.items() if v is not None} @@ -378,24 +431,30 @@ def get_host_port_url_tuple(scope): return server_host, port, http_url -def set_status_code(span, status_code): +def set_status_code( + span, + status_code, + metric_attributes=None, + sem_conv_opt_in_mode=_HTTPStabilityMode.DEFAULT, +): """Adds HTTP response attributes to span using the status_code argument.""" if not span.is_recording(): return + status_code_str = str(status_code) + try: status_code = int(status_code) 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 metric_attributes is None: + metric_attributes = {} + _set_status( + span, + metric_attributes, + status_code, + status_code_str, + sem_conv_opt_in_mode, + ) def get_default_span_details(scope: dict) -> Tuple[str, dict]: @@ -410,7 +469,9 @@ def get_default_span_details(scope: dict) -> Tuple[str, dict]: a tuple of the span name, and any attributes to attach to the span. """ path = scope.get("path", "").strip() - method = scope.get("method", "").strip() + method = sanitize_method(scope.get("method", "").strip()) + if method == "_OTHER": + method = "HTTP" if method and path: # http return f"{method} {path}", {} if path: # websocket @@ -484,13 +545,18 @@ def __init__( http_capture_headers_server_response: list[str] | None = None, http_capture_headers_sanitize_fields: list[str] | None = 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.app = guarantee_single_callable(app) 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), ) if tracer is None else tracer @@ -500,30 +566,51 @@ def __init__( __name__, __version__, meter_provider, - schema_url="https://opentelemetry.io/schemas/1.11.0", + schema_url=_get_schema_url(sem_conv_opt_in_mode), ) if meter is None else meter ) - self.duration_histogram = self.meter.create_histogram( - name=MetricInstruments.HTTP_SERVER_DURATION, - unit="ms", - description="Duration of HTTP client requests.", - ) - self.server_response_size_histogram = self.meter.create_histogram( - name=MetricInstruments.HTTP_SERVER_RESPONSE_SIZE, - unit="By", - description="measures the size of HTTP response messages (compressed).", - ) - self.server_request_size_histogram = self.meter.create_histogram( - name=MetricInstruments.HTTP_SERVER_REQUEST_SIZE, - unit="By", - description="Measures the size of HTTP request messages (compressed).", - ) - 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", + 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="Duration of HTTP server requests.", + ) + self.duration_histogram_new = None + if _report_new(sem_conv_opt_in_mode): + self.duration_histogram_new = self.meter.create_histogram( + name=HTTP_SERVER_REQUEST_DURATION, + description="Duration of HTTP server requests.", + unit="s", + ) + self.server_response_size_histogram = None + if _report_old(sem_conv_opt_in_mode): + self.server_response_size_histogram = self.meter.create_histogram( + name=MetricInstruments.HTTP_SERVER_RESPONSE_SIZE, + unit="By", + description="measures the size of HTTP response messages (compressed).", + ) + self.server_response_body_size_histogram = None + if _report_new(sem_conv_opt_in_mode): + self.server_response_body_size_histogram = ( + create_http_server_response_body_size(self.meter) + ) + self.server_request_size_histogram = None + if _report_old(sem_conv_opt_in_mode): + self.server_request_size_histogram = self.meter.create_histogram( + name=MetricInstruments.HTTP_SERVER_REQUEST_SIZE, + unit="By", + description="Measures the size of HTTP request messages (compressed).", + ) + self.server_request_body_size_histogram = None + if _report_new(sem_conv_opt_in_mode): + self.server_request_body_size_histogram = ( + create_http_server_request_body_size(self.meter) + ) + self.active_requests_counter = create_http_server_active_requests( + self.meter ) self.excluded_urls = excluded_urls self.default_span_details = ( @@ -533,6 +620,7 @@ def __init__( self.client_request_hook = client_request_hook self.client_response_hook = client_response_hook self.content_length_header = None + self._sem_conv_opt_in_mode = sem_conv_opt_in_mode # Environment variables as constructor parameters self.http_capture_headers_server_request = ( @@ -563,6 +651,7 @@ def __init__( or [] ) + # pylint: disable=too-many-statements async def __call__( self, scope: dict[str, Any], @@ -586,7 +675,9 @@ async def __call__( span_name, additional_attributes = self.default_span_details(scope) - attributes = collect_request_attributes(scope) + attributes = collect_request_attributes( + scope, self._sem_conv_opt_in_mode + ) attributes.update(additional_attributes) span, token = _start_internal_or_server_span( tracer=self.tracer, @@ -597,9 +688,9 @@ async def __call__( attributes=attributes, ) active_requests_count_attrs = _parse_active_request_count_attrs( - attributes + attributes, + self._sem_conv_opt_in_mode, ) - duration_attrs = _parse_duration_attrs(attributes) if scope["type"] == "http": self.active_requests_counter.add(1, active_requests_count_attrs) @@ -635,7 +726,7 @@ async def __call__( span_name, scope, send, - duration_attrs, + attributes, ) await self.app(scope, otel_receive, otel_send) @@ -643,16 +734,44 @@ async def __call__( if scope["type"] == "http": target = _collect_target_attribute(scope) if target: - duration_attrs[SpanAttributes.HTTP_TARGET] = target - duration = max(round((default_timer() - start) * 1000), 0) - self.duration_histogram.record(duration, duration_attrs) + path, query = _parse_url_query(target) + _set_http_target( + attributes, + target, + path, + query, + self._sem_conv_opt_in_mode, + ) + duration_s = default_timer() - start + duration_attrs_old = _parse_duration_attrs( + attributes, _HTTPStabilityMode.DEFAULT + ) + if target: + duration_attrs_old[SpanAttributes.HTTP_TARGET] = target + duration_attrs_new = _parse_duration_attrs( + attributes, _HTTPStabilityMode.HTTP + ) + if self.duration_histogram_old: + self.duration_histogram_old.record( + max(round(duration_s * 1000), 0), duration_attrs_old + ) + if self.duration_histogram_new: + self.duration_histogram_new.record( + max(duration_s, 0), duration_attrs_new + ) self.active_requests_counter.add( -1, active_requests_count_attrs ) if self.content_length_header: - self.server_response_size_histogram.record( - self.content_length_header, duration_attrs - ) + if self.server_response_size_histogram: + self.server_response_size_histogram.record( + self.content_length_header, duration_attrs_old + ) + if self.server_response_body_size_histogram: + self.server_response_body_size_histogram.record( + self.content_length_header, duration_attrs_new + ) + request_size = asgi_getter.get(scope, "content-length") if request_size: try: @@ -660,9 +779,14 @@ async def __call__( except ValueError: pass else: - self.server_request_size_histogram.record( - request_size_amount, duration_attrs - ) + if self.server_request_size_histogram: + self.server_request_size_histogram.record( + request_size_amount, duration_attrs_old + ) + if self.server_request_body_size_histogram: + self.server_request_body_size_histogram.record( + request_size_amount, duration_attrs_new + ) if token: context.detach(token) if span.is_recording(): @@ -681,7 +805,12 @@ async def otel_receive(): self.client_request_hook(receive_span, scope, message) if receive_span.is_recording(): if message["type"] == "websocket.receive": - set_status_code(receive_span, 200) + set_status_code( + receive_span, + 200, + None, + self._sem_conv_opt_in_mode, + ) receive_span.set_attribute( "asgi.event.type", message["type"] ) @@ -690,7 +819,12 @@ async def otel_receive(): return otel_receive def _get_otel_send( - self, server_span, server_span_name, scope, send, duration_attrs + self, + server_span, + server_span_name, + scope, + send, + duration_attrs, ): expecting_trailers = False @@ -705,16 +839,33 @@ async def otel_send(message: dict[str, Any]): if send_span.is_recording(): if message["type"] == "http.response.start": status_code = message["status"] - duration_attrs[SpanAttributes.HTTP_STATUS_CODE] = ( - status_code + # We record metrics only once + set_status_code( + server_span, + status_code, + duration_attrs, + self._sem_conv_opt_in_mode, + ) + set_status_code( + send_span, + status_code, + None, + self._sem_conv_opt_in_mode, ) - set_status_code(server_span, status_code) - set_status_code(send_span, status_code) - expecting_trailers = message.get("trailers", False) elif message["type"] == "websocket.send": - set_status_code(server_span, 200) - set_status_code(send_span, 200) + set_status_code( + server_span, + 200, + duration_attrs, + self._sem_conv_opt_in_mode, + ) + set_status_code( + send_span, + 200, + None, + self._sem_conv_opt_in_mode, + ) send_span.set_attribute("asgi.event.type", message["type"]) if ( server_span.is_recording() @@ -767,3 +918,25 @@ async def otel_send(message: dict[str, Any]): server_span.end() return otel_send + + +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 _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, + ) diff --git a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/package.py b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/package.py index c219ec5499..cd35b1f73a 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/package.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/package.py @@ -14,3 +14,7 @@ _instruments = ("asgiref ~= 3.0",) + +_supports_metrics = True + +_semconv_status = "migration" diff --git a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py index d2fe6bc52b..ff266cb5bf 100644 --- a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py @@ -23,6 +23,15 @@ import opentelemetry.instrumentation.asgi as otel_asgi 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.instrumentation.propagators import ( TraceResponsePropagator, get_global_response_propagator, @@ -33,6 +42,30 @@ HistogramDataPoint, NumberDataPoint, ) +from opentelemetry.semconv.attributes.client_attributes import ( + CLIENT_ADDRESS, + CLIENT_PORT, +) +from opentelemetry.semconv.attributes.http_attributes import ( + HTTP_REQUEST_METHOD, + HTTP_RESPONSE_STATUS_CODE, +) +from opentelemetry.semconv.attributes.network_attributes import ( + NETWORK_PROTOCOL_VERSION, +) +from opentelemetry.semconv.attributes.server_attributes import ( + SERVER_ADDRESS, + SERVER_PORT, +) +from opentelemetry.semconv.attributes.url_attributes import ( + URL_FULL, + URL_PATH, + URL_QUERY, + URL_SCHEME, +) +from opentelemetry.semconv.attributes.user_agent_attributes import ( + USER_AGENT_ORIGINAL, +) from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.test.asgitestutil import ( AsgiTestBase, @@ -40,24 +73,42 @@ ) from opentelemetry.test.test_base import TestBase from opentelemetry.trace import SpanKind, format_span_id, format_trace_id -from opentelemetry.util.http import ( - _active_requests_count_attrs, - _duration_attrs, -) -_expected_metric_names = [ +_expected_metric_names_old = [ "http.server.active_requests", "http.server.duration", "http.server.response.size", "http.server.request.size", ] -_recommended_attrs = { - "http.server.active_requests": _active_requests_count_attrs, - "http.server.duration": _duration_attrs, - "http.server.response.size": _duration_attrs, - "http.server.request.size": _duration_attrs, +_expected_metric_names_new = [ + "http.server.active_requests", + "http.server.request.duration", + "http.server.response.body.size", + "http.server.request.body.size", +] +_expected_metric_names_both = _expected_metric_names_old +_expected_metric_names_both.extend(_expected_metric_names_new) + +_recommended_attrs_old = { + "http.server.active_requests": _server_active_requests_count_attrs_old, + "http.server.duration": _server_duration_attrs_old, + "http.server.response.size": _server_duration_attrs_old, + "http.server.request.size": _server_duration_attrs_old, +} + +_recommended_attrs_new = { + "http.server.active_requests": _server_active_requests_count_attrs_new, + "http.server.request.duration": _server_duration_attrs_new, + "http.server.response.body.size": _server_duration_attrs_new, + "http.server.request.body.size": _server_duration_attrs_new, } +_recommended_attrs_both = _recommended_attrs_old.copy() +_recommended_attrs_both.update(_recommended_attrs_new) +_recommended_attrs_both["http.server.active_requests"].extend( + _server_active_requests_count_attrs_old +) + _SIMULATED_BACKGROUND_TASK_EXECUTION_TIME_S = 0.01 @@ -229,7 +280,37 @@ async def error_asgi(scope, receive, send): # pylint: disable=too-many-public-methods class TestAsgiApplication(AsgiTestBase): - def validate_outputs(self, outputs, error=None, modifiers=None): + 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() + + # pylint: disable=too-many-locals + def validate_outputs( + self, + outputs, + error=None, + modifiers=None, + old_sem_conv=True, + new_sem_conv=False, + ): # Ensure modifiers is a list modifiers = modifiers or [] # Check for expected outputs @@ -264,7 +345,7 @@ def validate_outputs(self, outputs, error=None, modifiers=None): # Check spans span_list = self.memory_exporter.get_finished_spans() - expected = [ + expected_old = [ { "name": "GET / http receive", "kind": trace_api.SpanKind.INTERNAL, @@ -300,6 +381,96 @@ def validate_outputs(self, outputs, error=None, modifiers=None): }, }, ] + expected_new = [ + { + "name": "GET / http receive", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"asgi.event.type": "http.request"}, + }, + { + "name": "GET / http send", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": { + HTTP_RESPONSE_STATUS_CODE: 200, + "asgi.event.type": "http.response.start", + }, + }, + { + "name": "GET / http send", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"asgi.event.type": "http.response.body"}, + }, + { + "name": "GET /", + "kind": trace_api.SpanKind.SERVER, + "attributes": { + HTTP_REQUEST_METHOD: "GET", + URL_SCHEME: "http", + SERVER_PORT: 80, + SERVER_ADDRESS: "127.0.0.1", + NETWORK_PROTOCOL_VERSION: "1.0", + URL_PATH: "/", + URL_FULL: "http://127.0.0.1/", + CLIENT_ADDRESS: "127.0.0.1", + CLIENT_PORT: 32767, + HTTP_RESPONSE_STATUS_CODE: 200, + }, + }, + ] + expected_both = [ + { + "name": "GET / http receive", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"asgi.event.type": "http.request"}, + }, + { + "name": "GET / http send", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": { + SpanAttributes.HTTP_STATUS_CODE: 200, + HTTP_RESPONSE_STATUS_CODE: 200, + "asgi.event.type": "http.response.start", + }, + }, + { + "name": "GET / http send", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"asgi.event.type": "http.response.body"}, + }, + { + "name": "GET /", + "kind": trace_api.SpanKind.SERVER, + "attributes": { + HTTP_REQUEST_METHOD: "GET", + URL_SCHEME: "http", + SERVER_PORT: 80, + SERVER_ADDRESS: "127.0.0.1", + NETWORK_PROTOCOL_VERSION: "1.0", + URL_PATH: "/", + URL_FULL: "http://127.0.0.1/", + CLIENT_ADDRESS: "127.0.0.1", + CLIENT_PORT: 32767, + HTTP_RESPONSE_STATUS_CODE: 200, + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_SCHEME: "http", + SpanAttributes.NET_HOST_PORT: 80, + SpanAttributes.HTTP_HOST: "127.0.0.1", + SpanAttributes.HTTP_FLAVOR: "1.0", + SpanAttributes.HTTP_TARGET: "/", + SpanAttributes.HTTP_URL: "http://127.0.0.1/", + SpanAttributes.NET_PEER_IP: "127.0.0.1", + SpanAttributes.NET_PEER_PORT: 32767, + SpanAttributes.HTTP_STATUS_CODE: 200, + }, + }, + ] + expected = expected_old + if new_sem_conv: + if old_sem_conv: + expected = expected_both + else: + expected = expected_new + # Run our expected modifiers for modifier in modifiers: expected = modifier(expected) @@ -322,6 +493,22 @@ def test_basic_asgi_call(self): outputs = self.get_all_output() self.validate_outputs(outputs) + def test_basic_asgi_call_new_semconv(self): + """Test that spans are emitted as expected.""" + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.validate_outputs(outputs, old_sem_conv=False, new_sem_conv=True) + + def test_basic_asgi_call_both_semconv(self): + """Test that spans are emitted as expected.""" + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.validate_outputs(outputs, old_sem_conv=True, new_sem_conv=True) + def test_asgi_not_recording(self): mock_tracer = mock.Mock() mock_span = mock.Mock() @@ -496,6 +683,59 @@ def update_expected_server(expected): outputs = self.get_all_output() self.validate_outputs(outputs, modifiers=[update_expected_server]) + def test_behavior_with_scope_server_as_none_new_semconv(self): + """Test that middleware is ok when server is none in scope.""" + + def update_expected_server(expected): + expected[3]["attributes"].update( + { + SERVER_ADDRESS: "0.0.0.0", + SERVER_PORT: 80, + URL_FULL: "http://0.0.0.0/", + } + ) + return expected + + self.scope["server"] = None + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.validate_outputs( + outputs, + modifiers=[update_expected_server], + old_sem_conv=False, + new_sem_conv=True, + ) + + def test_behavior_with_scope_server_as_none_both_semconv(self): + """Test that middleware is ok when server is none in scope.""" + + def update_expected_server(expected): + expected[3]["attributes"].update( + { + SpanAttributes.HTTP_HOST: "0.0.0.0", + SpanAttributes.NET_HOST_PORT: 80, + SpanAttributes.HTTP_URL: "http://0.0.0.0/", + SERVER_ADDRESS: "0.0.0.0", + SERVER_PORT: 80, + URL_FULL: "http://0.0.0.0/", + } + ) + return expected + + self.scope["server"] = None + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.validate_outputs( + outputs, + modifiers=[update_expected_server], + old_sem_conv=True, + new_sem_conv=True, + ) + def test_host_header(self): """Test that host header is converted to http.server_name.""" hostname = b"server_name_1" @@ -513,6 +753,28 @@ def update_expected_server(expected): outputs = self.get_all_output() self.validate_outputs(outputs, modifiers=[update_expected_server]) + def test_host_header_both_semconv(self): + """Test that host header is converted to http.server_name.""" + hostname = b"server_name_1" + + def update_expected_server(expected): + expected[3]["attributes"].update( + {SpanAttributes.HTTP_SERVER_NAME: hostname.decode("utf8")} + ) + return expected + + self.scope["headers"].append([b"host", hostname]) + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.validate_outputs( + outputs, + modifiers=[update_expected_server], + old_sem_conv=True, + new_sem_conv=True, + ) + def test_user_agent(self): """Test that host header is converted to http.server_name.""" user_agent = b"test-agent" @@ -530,6 +792,53 @@ def update_expected_user_agent(expected): outputs = self.get_all_output() self.validate_outputs(outputs, modifiers=[update_expected_user_agent]) + def test_user_agent_new_semconv(self): + """Test that host header is converted to http.server_name.""" + user_agent = b"test-agent" + + def update_expected_user_agent(expected): + expected[3]["attributes"].update( + {USER_AGENT_ORIGINAL: user_agent.decode("utf8")} + ) + return expected + + self.scope["headers"].append([b"user-agent", user_agent]) + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.validate_outputs( + outputs, + modifiers=[update_expected_user_agent], + old_sem_conv=False, + new_sem_conv=True, + ) + + def test_user_agent_both_semconv(self): + """Test that host header is converted to http.server_name.""" + user_agent = b"test-agent" + + def update_expected_user_agent(expected): + expected[3]["attributes"].update( + { + SpanAttributes.HTTP_USER_AGENT: user_agent.decode("utf8"), + USER_AGENT_ORIGINAL: user_agent.decode("utf8"), + } + ) + return expected + + self.scope["headers"].append([b"user-agent", user_agent]) + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.validate_outputs( + outputs, + modifiers=[update_expected_user_agent], + old_sem_conv=True, + new_sem_conv=True, + ) + def test_traceresponse_header(self): """Test a traceresponse header is sent when a global propagator is set.""" @@ -565,6 +874,7 @@ def test_traceresponse_header(self): def test_websocket(self): self.scope = { + "method": "GET", "type": "websocket", "http_version": "1.1", "scheme": "ws", @@ -584,17 +894,17 @@ def test_websocket(self): self.assertEqual(len(span_list), 6) expected = [ { - "name": "/ websocket receive", + "name": "GET / websocket receive", "kind": trace_api.SpanKind.INTERNAL, "attributes": {"asgi.event.type": "websocket.connect"}, }, { - "name": "/ websocket send", + "name": "GET / websocket send", "kind": trace_api.SpanKind.INTERNAL, "attributes": {"asgi.event.type": "websocket.accept"}, }, { - "name": "/ websocket receive", + "name": "GET / websocket receive", "kind": trace_api.SpanKind.INTERNAL, "attributes": { "asgi.event.type": "websocket.receive", @@ -602,7 +912,7 @@ def test_websocket(self): }, }, { - "name": "/ websocket send", + "name": "GET / websocket send", "kind": trace_api.SpanKind.INTERNAL, "attributes": { "asgi.event.type": "websocket.send", @@ -610,12 +920,12 @@ def test_websocket(self): }, }, { - "name": "/ websocket receive", + "name": "GET / websocket receive", "kind": trace_api.SpanKind.INTERNAL, "attributes": {"asgi.event.type": "websocket.disconnect"}, }, { - "name": "/", + "name": "GET /", "kind": trace_api.SpanKind.SERVER, "attributes": { SpanAttributes.HTTP_SCHEME: self.scope["scheme"], @@ -627,6 +937,167 @@ def test_websocket(self): SpanAttributes.NET_PEER_IP: self.scope["client"][0], SpanAttributes.NET_PEER_PORT: self.scope["client"][1], SpanAttributes.HTTP_STATUS_CODE: 200, + SpanAttributes.HTTP_METHOD: self.scope["method"], + }, + }, + ] + for span, expected in zip(span_list, expected): + self.assertEqual(span.name, expected["name"]) + self.assertEqual(span.kind, expected["kind"]) + self.assertDictEqual(dict(span.attributes), expected["attributes"]) + + def test_websocket_new_semconv(self): + self.scope = { + "method": "GET", + "type": "websocket", + "http_version": "1.1", + "scheme": "ws", + "path": "/", + "query_string": b"", + "headers": [], + "client": ("127.0.0.1", 32767), + "server": ("127.0.0.1", 80), + } + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_input({"type": "websocket.connect"}) + self.send_input({"type": "websocket.receive", "text": "ping"}) + self.send_input({"type": "websocket.disconnect"}) + self.get_all_output() + span_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(span_list), 6) + expected = [ + { + "name": "GET / websocket receive", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"asgi.event.type": "websocket.connect"}, + }, + { + "name": "GET / websocket send", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"asgi.event.type": "websocket.accept"}, + }, + { + "name": "GET / websocket receive", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": { + "asgi.event.type": "websocket.receive", + HTTP_RESPONSE_STATUS_CODE: 200, + }, + }, + { + "name": "GET / websocket send", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": { + "asgi.event.type": "websocket.send", + HTTP_RESPONSE_STATUS_CODE: 200, + }, + }, + { + "name": "GET / websocket receive", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"asgi.event.type": "websocket.disconnect"}, + }, + { + "name": "GET /", + "kind": trace_api.SpanKind.SERVER, + "attributes": { + URL_SCHEME: self.scope["scheme"], + SERVER_PORT: self.scope["server"][1], + SERVER_ADDRESS: self.scope["server"][0], + NETWORK_PROTOCOL_VERSION: self.scope["http_version"], + URL_PATH: self.scope["path"], + URL_FULL: f'{self.scope["scheme"]}://{self.scope["server"][0]}{self.scope["path"]}', + CLIENT_ADDRESS: self.scope["client"][0], + CLIENT_PORT: self.scope["client"][1], + HTTP_RESPONSE_STATUS_CODE: 200, + HTTP_REQUEST_METHOD: self.scope["method"], + }, + }, + ] + for span, expected in zip(span_list, expected): + self.assertEqual(span.name, expected["name"]) + self.assertEqual(span.kind, expected["kind"]) + self.assertDictEqual(dict(span.attributes), expected["attributes"]) + + def test_websocket_both_semconv(self): + self.scope = { + "method": "GET", + "type": "websocket", + "http_version": "1.1", + "scheme": "ws", + "path": "/", + "query_string": b"", + "headers": [], + "client": ("127.0.0.1", 32767), + "server": ("127.0.0.1", 80), + } + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_input({"type": "websocket.connect"}) + self.send_input({"type": "websocket.receive", "text": "ping"}) + self.send_input({"type": "websocket.disconnect"}) + self.get_all_output() + span_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(span_list), 6) + expected = [ + { + "name": "GET / websocket receive", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"asgi.event.type": "websocket.connect"}, + }, + { + "name": "GET / websocket send", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"asgi.event.type": "websocket.accept"}, + }, + { + "name": "GET / websocket receive", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": { + "asgi.event.type": "websocket.receive", + HTTP_RESPONSE_STATUS_CODE: 200, + SpanAttributes.HTTP_STATUS_CODE: 200, + }, + }, + { + "name": "GET / websocket send", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": { + "asgi.event.type": "websocket.send", + HTTP_RESPONSE_STATUS_CODE: 200, + SpanAttributes.HTTP_STATUS_CODE: 200, + }, + }, + { + "name": "GET / websocket receive", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"asgi.event.type": "websocket.disconnect"}, + }, + { + "name": "GET /", + "kind": trace_api.SpanKind.SERVER, + "attributes": { + SpanAttributes.HTTP_SCHEME: self.scope["scheme"], + SpanAttributes.NET_HOST_PORT: self.scope["server"][1], + SpanAttributes.HTTP_HOST: self.scope["server"][0], + SpanAttributes.HTTP_FLAVOR: self.scope["http_version"], + SpanAttributes.HTTP_TARGET: self.scope["path"], + SpanAttributes.HTTP_URL: f'{self.scope["scheme"]}://{self.scope["server"][0]}{self.scope["path"]}', + SpanAttributes.NET_PEER_IP: self.scope["client"][0], + SpanAttributes.NET_PEER_PORT: self.scope["client"][1], + SpanAttributes.HTTP_STATUS_CODE: 200, + SpanAttributes.HTTP_METHOD: self.scope["method"], + URL_SCHEME: self.scope["scheme"], + SERVER_PORT: self.scope["server"][1], + SERVER_ADDRESS: self.scope["server"][0], + NETWORK_PROTOCOL_VERSION: self.scope["http_version"], + URL_PATH: self.scope["path"], + URL_FULL: f'{self.scope["scheme"]}://{self.scope["server"][0]}{self.scope["path"]}', + CLIENT_ADDRESS: self.scope["client"][0], + CLIENT_PORT: self.scope["client"][1], + HTTP_RESPONSE_STATUS_CODE: 200, + HTTP_REQUEST_METHOD: self.scope["method"], }, }, ] @@ -737,7 +1208,7 @@ def test_asgi_metrics(self): "opentelemetry.instrumentation.asgi", ) 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: @@ -748,7 +1219,79 @@ def test_asgi_metrics(self): number_data_point_seen = True for attr in point.attributes: self.assertIn( - attr, _recommended_attrs[metric.name] + attr, _recommended_attrs_old[metric.name] + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + + def test_asgi_metrics_new_semconv(self): + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + self.seed_app(app) + self.send_default_request() + self.seed_app(app) + self.send_default_request() + 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) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, _recommended_attrs_new[metric.name] + ) + self.assertTrue(number_data_point_seen and histogram_data_point_seen) + + def test_asgi_metrics_both_semconv(self): + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + self.seed_app(app) + self.send_default_request() + self.seed_app(app) + self.send_default_request() + 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) + histogram_data_point_seen = True + if isinstance(point, NumberDataPoint): + number_data_point_seen = True + for attr in point.attributes: + self.assertIn( + attr, _recommended_attrs_both[metric.name] ) self.assertTrue(number_data_point_seen and histogram_data_point_seen) @@ -799,6 +1342,141 @@ def test_basic_metric_success(self): ) self.assertEqual(point.value, 0) + def test_basic_metric_success_new_semconv(self): + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + start = default_timer() + self.send_default_request() + duration_s = max(default_timer() - start, 0) + expected_duration_attributes = { + "http.request.method": "GET", + "url.scheme": "http", + "network.protocol.version": "1.0", + "http.response.status_code": 200, + } + expected_requests_count_attributes = { + "http.request.method": "GET", + "url.scheme": "http", + } + 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_metrics in resource_metric.scope_metrics: + for metric in scope_metrics.metrics: + for point in list(metric.data.data_points): + if isinstance(point, HistogramDataPoint): + self.assertDictEqual( + expected_duration_attributes, + dict(point.attributes), + ) + self.assertEqual(point.count, 1) + if metric.name == "http.server.request.duration": + self.assertAlmostEqual( + duration_s, point.sum, places=2 + ) + elif ( + metric.name == "http.server.response.body.size" + ): + self.assertEqual(1024, point.sum) + elif ( + metric.name == "http.server.request.body.size" + ): + self.assertEqual(128, point.sum) + elif isinstance(point, NumberDataPoint): + self.assertDictEqual( + expected_requests_count_attributes, + dict(point.attributes), + ) + self.assertEqual(point.value, 0) + + def test_basic_metric_success_both_semconv(self): + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + start = default_timer() + self.send_default_request() + duration = max(round((default_timer() - start) * 1000), 0) + duration_s = max(default_timer() - start, 0) + expected_duration_attributes_old = { + "http.method": "GET", + "http.host": "127.0.0.1", + "http.scheme": "http", + "http.flavor": "1.0", + "net.host.port": 80, + "http.status_code": 200, + } + expected_requests_count_attributes = { + "http.method": "GET", + "http.host": "127.0.0.1", + "http.scheme": "http", + "http.flavor": "1.0", + "http.request.method": "GET", + "url.scheme": "http", + } + expected_duration_attributes_new = { + "http.request.method": "GET", + "url.scheme": "http", + "network.protocol.version": "1.0", + "http.response.status_code": 200, + } + 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_metrics in resource_metric.scope_metrics: + for metric in scope_metrics.metrics: + for point in list(metric.data.data_points): + if isinstance(point, HistogramDataPoint): + self.assertEqual(point.count, 1) + if metric.name == "http.server.request.duration": + self.assertAlmostEqual( + duration_s, point.sum, places=2 + ) + self.assertDictEqual( + expected_duration_attributes_new, + dict(point.attributes), + ) + elif ( + metric.name == "http.server.response.body.size" + ): + self.assertEqual(1024, point.sum) + self.assertDictEqual( + expected_duration_attributes_new, + dict(point.attributes), + ) + elif ( + metric.name == "http.server.request.body.size" + ): + self.assertEqual(128, point.sum) + self.assertDictEqual( + expected_duration_attributes_new, + dict(point.attributes), + ) + elif metric.name == "http.server.duration": + self.assertAlmostEqual( + duration, point.sum, delta=5 + ) + self.assertDictEqual( + expected_duration_attributes_old, + dict(point.attributes), + ) + elif metric.name == "http.server.response.size": + self.assertEqual(1024, point.sum) + self.assertDictEqual( + expected_duration_attributes_old, + dict(point.attributes), + ) + elif metric.name == "http.server.request.size": + self.assertEqual(128, point.sum) + self.assertDictEqual( + expected_duration_attributes_old, + dict(point.attributes), + ) + elif isinstance(point, NumberDataPoint): + self.assertDictEqual( + expected_requests_count_attributes, + dict(point.attributes), + ) + self.assertEqual(point.value, 0) + def test_metric_target_attribute(self): expected_target = "/api/user/{id}" @@ -882,6 +1560,70 @@ def test_request_attributes(self): }, ) + def test_request_attributes_new_semconv(self): + self.scope["query_string"] = b"foo=bar" + headers = [] + headers.append((b"host", b"test")) + self.scope["headers"] = headers + + attrs = otel_asgi.collect_request_attributes( + self.scope, + _HTTPStabilityMode.HTTP, + ) + + self.assertDictEqual( + attrs, + { + HTTP_REQUEST_METHOD: "GET", + SERVER_ADDRESS: "127.0.0.1", + URL_PATH: "/", + URL_QUERY: "foo=bar", + URL_FULL: "http://127.0.0.1/?foo=bar", + SERVER_PORT: 80, + URL_SCHEME: "http", + NETWORK_PROTOCOL_VERSION: "1.0", + CLIENT_ADDRESS: "127.0.0.1", + CLIENT_PORT: 32767, + }, + ) + + def test_request_attributes_both_semconv(self): + self.scope["query_string"] = b"foo=bar" + headers = [] + headers.append((b"host", b"test")) + self.scope["headers"] = headers + + attrs = otel_asgi.collect_request_attributes( + self.scope, + _HTTPStabilityMode.HTTP_DUP, + ) + + self.assertDictEqual( + attrs, + { + SpanAttributes.HTTP_METHOD: "GET", + SpanAttributes.HTTP_HOST: "127.0.0.1", + SpanAttributes.HTTP_TARGET: "/", + SpanAttributes.HTTP_URL: "http://127.0.0.1/?foo=bar", + SpanAttributes.NET_HOST_PORT: 80, + SpanAttributes.HTTP_SCHEME: "http", + SpanAttributes.HTTP_SERVER_NAME: "test", + SpanAttributes.HTTP_FLAVOR: "1.0", + SpanAttributes.NET_PEER_IP: "127.0.0.1", + SpanAttributes.NET_PEER_PORT: 32767, + HTTP_REQUEST_METHOD: "GET", + SERVER_ADDRESS: "127.0.0.1", + URL_PATH: "/", + URL_QUERY: "foo=bar", + URL_FULL: "http://127.0.0.1/?foo=bar", + SERVER_PORT: 80, + URL_SCHEME: "http", + NETWORK_PROTOCOL_VERSION: "1.0", + CLIENT_ADDRESS: "127.0.0.1", + CLIENT_PORT: 32767, + }, + ) + def test_query_string(self): self.scope["query_string"] = b"foo=bar" attrs = otel_asgi.collect_request_attributes(self.scope) @@ -889,6 +1631,25 @@ def test_query_string(self): attrs[SpanAttributes.HTTP_URL], "http://127.0.0.1/?foo=bar" ) + def test_query_string_new_semconv(self): + self.scope["query_string"] = b"foo=bar" + attrs = otel_asgi.collect_request_attributes( + self.scope, + _HTTPStabilityMode.HTTP, + ) + self.assertEqual(attrs[URL_FULL], "http://127.0.0.1/?foo=bar") + + def test_query_string_both_semconv(self): + self.scope["query_string"] = b"foo=bar" + attrs = otel_asgi.collect_request_attributes( + self.scope, + _HTTPStabilityMode.HTTP_DUP, + ) + self.assertEqual(attrs[URL_FULL], "http://127.0.0.1/?foo=bar") + self.assertEqual( + attrs[SpanAttributes.HTTP_URL], "http://127.0.0.1/?foo=bar" + ) + def test_query_string_percent_bytes(self): self.scope["query_string"] = b"foo%3Dbar" attrs = otel_asgi.collect_request_attributes(self.scope) @@ -910,6 +1671,32 @@ def test_response_attributes(self): self.assertEqual(self.span.set_attribute.call_count, 1) self.span.set_attribute.assert_has_calls(expected, any_order=True) + def test_response_attributes_new_semconv(self): + otel_asgi.set_status_code( + self.span, + 404, + None, + _HTTPStabilityMode.HTTP, + ) + expected = (mock.call(HTTP_RESPONSE_STATUS_CODE, 404),) + self.assertEqual(self.span.set_attribute.call_count, 1) + self.assertEqual(self.span.set_attribute.call_count, 1) + self.span.set_attribute.assert_has_calls(expected, any_order=True) + + def test_response_attributes_both_semconv(self): + otel_asgi.set_status_code( + self.span, + 404, + None, + _HTTPStabilityMode.HTTP_DUP, + ) + expected = (mock.call(SpanAttributes.HTTP_STATUS_CODE, 404),) + expected2 = (mock.call(HTTP_RESPONSE_STATUS_CODE, 404),) + self.assertEqual(self.span.set_attribute.call_count, 2) + 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(expected2, any_order=True) + def test_response_attributes_invalid_status_code(self): otel_asgi.set_status_code(self.span, "Invalid Status Code") self.assertEqual(self.span.set_status.call_count, 1) diff --git a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py index bf7f1d4f49..dbc2512ca0 100644 --- a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py +++ b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py @@ -345,8 +345,6 @@ 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") diff --git a/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py b/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py index 3f47eebcca..94437bbfd2 100644 --- a/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py +++ b/instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py @@ -584,8 +584,6 @@ 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, @@ -627,8 +625,6 @@ def test_basic_metric_nonstandard_http_method_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 b1d854b371..b40cf3355a 100644 --- a/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py +++ b/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py @@ -224,8 +224,6 @@ 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-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py index 6a1883fa7e..d75147d6aa 100644 --- a/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py @@ -494,8 +494,8 @@ def add_response_attributes( _set_status( span, duration_attrs, - status_code_str, status_code, + status_code_str, sem_conv_opt_in_mode, ) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py index baa06ff99b..85b8e2e3ec 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py @@ -17,6 +17,10 @@ from enum import Enum from opentelemetry.instrumentation.utils import http_status_to_status_code +from opentelemetry.semconv.attributes.client_attributes import ( + CLIENT_ADDRESS, + CLIENT_PORT, +) from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE from opentelemetry.semconv.attributes.http_attributes import ( HTTP_REQUEST_METHOD, @@ -33,11 +37,18 @@ ) from opentelemetry.semconv.attributes.url_attributes import ( URL_FULL, + URL_PATH, + URL_QUERY, URL_SCHEME, ) +from opentelemetry.semconv.attributes.user_agent_attributes import ( + USER_AGENT_ORIGINAL, +) from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.trace.status import Status, StatusCode +# These lists represent attributes for metrics that are currently supported + _client_duration_attrs_old = [ SpanAttributes.HTTP_STATUS_CODE, SpanAttributes.HTTP_HOST, @@ -85,13 +96,12 @@ SpanAttributes.HTTP_SCHEME, SpanAttributes.HTTP_FLAVOR, SpanAttributes.HTTP_SERVER_NAME, - SpanAttributes.NET_HOST_NAME, - SpanAttributes.NET_HOST_PORT, ] _server_active_requests_count_attrs_new = [ HTTP_REQUEST_METHOD, URL_SCHEME, + # TODO: Support SERVER_ADDRESS AND SERVER_PORT ] OTEL_SEMCONV_STABILITY_OPT_IN = "OTEL_SEMCONV_STABILITY_OPT_IN" @@ -280,14 +290,14 @@ 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) + set_string_attribute(result, SERVER_ADDRESS, host) 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) + set_int_attribute(result, SERVER_PORT, port) def _set_http_target(result, target, path, query, sem_conv_opt_in_mode): @@ -295,23 +305,23 @@ def _set_http_target(result, target, path, query, 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) + set_string_attribute(result, URL_PATH, path) if query: - set_string_attribute(result, SpanAttributes.URL_QUERY, query) + set_string_attribute(result, URL_QUERY, query) 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) + set_string_attribute(result, CLIENT_ADDRESS, ip) 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) + set_int_attribute(result, CLIENT_PORT, port) def _set_http_user_agent(result, user_agent, sem_conv_opt_in_mode): @@ -320,32 +330,28 @@ def _set_http_user_agent(result, user_agent, sem_conv_opt_in_mode): 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 - ) + set_string_attribute(result, 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) + set_string_attribute(result, 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 - ) + set_string_attribute(result, NETWORK_PROTOCOL_VERSION, version) def _set_status( span, metrics_attributes, - status_code_str, status_code, + status_code_str, sem_conv_opt_in_mode, ): if status_code < 0: @@ -366,12 +372,8 @@ def _set_status( 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 - ) + span.set_attribute(HTTP_RESPONSE_STATUS_CODE, status_code) + metrics_attributes[HTTP_RESPONSE_STATUS_CODE] = status_code if status == StatusCode.ERROR: span.set_attribute(ERROR_TYPE, status_code_str) metrics_attributes[ERROR_TYPE] = status_code_str