diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cbbe7aa3b..58de4960a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3967](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3967)) - `opentelemetry-instrumentation-redis`: add missing copyright header for opentelemetry-instrumentation-redis ([#3976](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3976)) +- `opentelemetry-instrumentation-aiohttp-server` Implement new semantic convention opt-in migration + ([#3980](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3980)) ### Fixed diff --git a/instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/__init__.py b/instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/__init__.py index 30f967d39f..57a1e591e1 100644 --- a/instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/__init__.py @@ -157,84 +157,95 @@ async def hello(request): from multidict import CIMultiDictProxy from opentelemetry import metrics, trace +from opentelemetry.instrumentation._semconv import ( + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + _filter_semconv_active_request_count_attr, + _filter_semconv_duration_attrs, + _get_schema_url, + _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_scheme, + _set_http_target, + _set_http_user_agent, + _set_status, + _StabilityMode, +) from opentelemetry.instrumentation.aiohttp_server.package import _instruments from opentelemetry.instrumentation.aiohttp_server.version import __version__ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.utils import ( - http_status_to_status_code, is_http_instrumentation_enabled, ) from opentelemetry.propagate import extract from opentelemetry.propagators.textmap import Getter from opentelemetry.semconv._incubating.attributes.http_attributes import ( - HTTP_FLAVOR, HTTP_HOST, - HTTP_METHOD, HTTP_ROUTE, - HTTP_SCHEME, HTTP_SERVER_NAME, - HTTP_STATUS_CODE, - HTTP_TARGET, HTTP_URL, - HTTP_USER_AGENT, -) -from opentelemetry.semconv._incubating.attributes.net_attributes import ( - NET_HOST_NAME, - NET_HOST_PORT, ) +from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE from opentelemetry.semconv.metrics import MetricInstruments +from opentelemetry.semconv.metrics.http_metrics import ( + HTTP_SERVER_REQUEST_DURATION, +) from opentelemetry.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_url_query, get_custom_headers, get_excluded_urls, normalise_request_header_name, normalise_response_header_name, + redact_query_parameters, redact_url, + sanitize_method, ) -_duration_attrs = [ - HTTP_METHOD, - HTTP_HOST, - HTTP_SCHEME, - HTTP_STATUS_CODE, - HTTP_FLAVOR, - HTTP_SERVER_NAME, - NET_HOST_NAME, - NET_HOST_PORT, - HTTP_ROUTE, -] - -_active_requests_count_attrs = [ - HTTP_METHOD, - HTTP_HOST, - HTTP_SCHEME, - HTTP_FLAVOR, - HTTP_SERVER_NAME, -] - tracer = None -meter = None +meter_old = None +meter_new = None +duration_histogram_old = None +duration_histogram_new = None +active_requests_counter = None _excluded_urls = None +_sem_conv_opt_in_mode = _StabilityMode.DEFAULT -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_active_request_count_attrs( + req_attrs, sem_conv_opt_in_mode: _StabilityMode = _StabilityMode.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_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_duration_attrs( + req_attrs: dict[str, str | None], + sem_conv_opt_in_mode: _StabilityMode = _StabilityMode.DEFAULT, +): + return _filter_semconv_duration_attrs( + req_attrs, + _server_duration_attrs_old, + _server_duration_attrs_new, + sem_conv_opt_in_mode, + ) def get_default_span_name(request: web.Request) -> str: @@ -261,47 +272,71 @@ def _get_view_func(request: web.Request) -> str: return "unknown" -def collect_request_attributes(request: web.Request) -> dict: - """Collects HTTP request attributes from the ASGI scope and returns a - dictionary to be used as span creation attributes.""" - - server_host, port, http_url = ( - request.url.host, - request.url.port, - str(request.url), +def collect_request_attributes( + request: web.Request, + sem_conv_opt_in_mode: _StabilityMode = _StabilityMode.DEFAULT, +) -> dict: + """Collects HTTP request attributes from the aiohttp request context + and returns a dictionary to be used as span creation attributes.""" + + result: dict[str, str | None] = {} + + _set_http_method( + result, + request.method, + sanitize_method(request.method), + sem_conv_opt_in_mode, ) + _set_http_scheme( + result, + request.scheme, + sem_conv_opt_in_mode, + ) + + server_host = request.url.host + port = request.url.port + if server_host: + _set_http_net_host(result, server_host, sem_conv_opt_in_mode) + if _report_old(sem_conv_opt_in_mode): + result[HTTP_HOST] = server_host + if port: + _set_http_net_host_port(result, port, sem_conv_opt_in_mode) + path = request.path query_string = request.query_string - if query_string and http_url: - if isinstance(query_string, bytes): - query_string = query_string.decode("utf8") - http_url += "?" + urllib.parse.unquote(query_string) - - result = { - HTTP_SCHEME: request.scheme, - HTTP_HOST: server_host, - NET_HOST_PORT: port, - HTTP_ROUTE: _get_view_func(request), - HTTP_FLAVOR: f"{request.version.major}.{request.version.minor}", - HTTP_TARGET: request.path, - HTTP_URL: redact_url(http_url), - } - - http_method = request.method - if http_method: - result[HTTP_METHOD] = http_method - - http_host_value_list = ( - [request.host] if not isinstance(request.host, list) else request.host - ) - if http_host_value_list: - result[HTTP_SERVER_NAME] = ",".join(http_host_value_list) - http_user_agent = request.headers.get("user-agent") - if http_user_agent: - result[HTTP_USER_AGENT] = http_user_agent + target = path + if query_string: + target = f"{path}?{query_string}" + if target: + redacted_target = redact_query_parameters(target) + _, redacted_query = _parse_url_query(redacted_target) + _set_http_target( + result, redacted_target, path, redacted_query, sem_conv_opt_in_mode + ) - # remove None values - result = {k: v for k, v in result.items() if v is not None} + # old semconv v1.20.0 - always set HTTP_URL when reporting old semconv + if _report_old(sem_conv_opt_in_mode): + http_url = str(request.url) + if query_string: + http_url += "?" + urllib.parse.unquote(query_string) + result[HTTP_URL] = redact_url(http_url) + + user_agent = request.headers.get("user-agent") + if user_agent: + _set_http_user_agent(result, user_agent, sem_conv_opt_in_mode) + + flavor = f"{request.version.major}.{request.version.minor}" + _set_http_flavor_version(result, flavor, sem_conv_opt_in_mode) + + if _report_old(sem_conv_opt_in_mode): + result[HTTP_ROUTE] = _get_view_func(request) + http_host_value_list = ( + [request.host] + if not isinstance(request.host, list) + else request.host + ) + if http_host_value_list: + result[HTTP_SERVER_NAME] = ",".join(http_host_value_list) return result @@ -342,11 +377,19 @@ def collect_response_headers_attributes( ) -def set_status_code(span, status_code: int) -> None: +def set_status_code( + span: trace.Span, + status_code: int, + duration_attrs: dict[str, str | None] | None = None, + sem_conv_opt_in_mode: _StabilityMode = _StabilityMode.DEFAULT, +) -> None: """Adds HTTP response attributes to span using the status_code argument.""" + if duration_attrs is None: + duration_attrs = {} try: - status_code = int(status_code) + status_code_int = int(status_code) + status_code_str = str(status_code) except ValueError: span.set_status( Status( @@ -355,9 +398,13 @@ def set_status_code(span, status_code: int) -> None: ) ) else: - span.set_attribute(HTTP_STATUS_CODE, status_code) - span.set_status( - Status(http_status_to_status_code(status_code, server_span=True)) + _set_status( + span, + duration_attrs, + status_code_int, + status_code_str, + server_span=True, + sem_conv_opt_in_mode=sem_conv_opt_in_mode, ) @@ -365,12 +412,12 @@ class AiohttpGetter(Getter): """Extract current trace from headers""" def get(self, carrier, key: str) -> list | None: - """Getter implementation to retrieve an HTTP header value from the ASGI - scope. + """Getter implementation to retrieve an HTTP header value from the aiohttp + request context. Args: - carrier: ASGI scope object - key: header name in scope + carrier: aiohttp request context object + key: header name in request context Returns: A list of all header values matching the key, or None if the key does not match any header. @@ -397,22 +444,10 @@ async def middleware(request, handler): span_name = get_default_span_name(request) - request_attrs = collect_request_attributes(request) - duration_attrs = _parse_duration_attrs(request_attrs) + request_attrs = collect_request_attributes(request, _sem_conv_opt_in_mode) active_requests_count_attrs = _parse_active_request_count_attrs( - request_attrs - ) - - duration_histogram = meter.create_histogram( - name=MetricInstruments.HTTP_SERVER_DURATION, - unit="ms", - description="Measures the duration of inbound HTTP requests.", - ) - - active_requests_counter = meter.create_up_down_counter( - name=MetricInstruments.HTTP_SERVER_ACTIVE_REQUESTS, - unit="requests", - description="measures the number of concurrent HTTP requests those are currently in flight", + request_attrs, + _sem_conv_opt_in_mode, ) with tracer.start_as_current_span( @@ -430,18 +465,45 @@ async def middleware(request, handler): active_requests_counter.add(1, active_requests_count_attrs) try: resp = await handler(request) - set_status_code(span, resp.status) + set_status_code( + span, + resp.status, + request_attrs, + _sem_conv_opt_in_mode, + ) if span.is_recording(): response_headers_attributes = ( collect_response_headers_attributes(resp) ) span.set_attributes(response_headers_attributes) except web.HTTPException as ex: - set_status_code(span, ex.status_code) + if _report_new(_sem_conv_opt_in_mode): + request_attrs[ERROR_TYPE] = type(ex).__qualname__ + if span.is_recording(): + span.set_attribute(ERROR_TYPE, type(ex).__qualname__) + set_status_code( + span, + ex.status_code, + request_attrs, + _sem_conv_opt_in_mode, + ) raise finally: - duration = max((default_timer() - start) * 1000, 0) - duration_histogram.record(duration, duration_attrs) + duration_s = default_timer() - start + if duration_histogram_old: + duration_attrs_old = _parse_duration_attrs( + request_attrs, _StabilityMode.DEFAULT + ) + duration_histogram_old.record( + max(round(duration_s * 1000), 0), duration_attrs_old + ) + if duration_histogram_new: + duration_attrs_new = _parse_duration_attrs( + request_attrs, _StabilityMode.HTTP + ) + duration_histogram_new.record( + max(duration_s, 0), duration_attrs_new + ) active_requests_counter.add(-1, active_requests_count_attrs) return resp @@ -464,15 +526,62 @@ class AioHttpServerInstrumentor(BaseInstrumentor): """ def _instrument(self, **kwargs): + _OpenTelemetrySemanticConventionStability._initialize() + sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP, + ) + # update global values at instrument time so we can test them global _excluded_urls # pylint: disable=global-statement _excluded_urls = get_excluded_urls("AIOHTTP_SERVER") + global _sem_conv_opt_in_mode # pylint: disable=global-statement + _sem_conv_opt_in_mode = sem_conv_opt_in_mode + global tracer # pylint: disable=global-statement - tracer = trace.get_tracer(__name__) + tracer = trace.get_tracer( + __name__, + __version__, + schema_url=_get_schema_url(sem_conv_opt_in_mode), + ) + + global meter_old # pylint: disable=global-statement + global meter_new # pylint: disable=global-statement + global duration_histogram_old # pylint: disable=global-statement + global duration_histogram_new # pylint: disable=global-statement + global active_requests_counter # pylint: disable=global-statement + + if _report_old(sem_conv_opt_in_mode): + meter_old = metrics.get_meter( + __name__, + __version__, + schema_url=_get_schema_url(_StabilityMode.DEFAULT), + ) + duration_histogram_old = meter_old.create_histogram( + name=MetricInstruments.HTTP_SERVER_DURATION, + unit="ms", + description="Measures the duration of inbound HTTP requests.", + ) - global meter # pylint: disable=global-statement - meter = metrics.get_meter(__name__, __version__) + if _report_new(sem_conv_opt_in_mode): + meter_new = metrics.get_meter( + __name__, + __version__, + schema_url=_get_schema_url(_StabilityMode.HTTP), + ) + duration_histogram_new = meter_new.create_histogram( + name=HTTP_SERVER_REQUEST_DURATION, + unit="s", + description="Duration of HTTP server requests.", + explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + ) + + meter_for_counter = meter_new if meter_new else meter_old + active_requests_counter = meter_for_counter.create_up_down_counter( + name=MetricInstruments.HTTP_SERVER_ACTIVE_REQUESTS, + unit="{request}", + description="Number of active HTTP server requests.", + ) self._original_app = web.Application setattr(web, "Application", _InstrumentedApplication) diff --git a/instrumentation/opentelemetry-instrumentation-aiohttp-server/tests/test_aiohttp_server_integration.py b/instrumentation/opentelemetry-instrumentation-aiohttp-server/tests/test_aiohttp_server_integration.py index 1528edf012..9a0a85ffc1 100644 --- a/instrumentation/opentelemetry-instrumentation-aiohttp-server/tests/test_aiohttp_server_integration.py +++ b/instrumentation/opentelemetry-instrumentation-aiohttp-server/tests/test_aiohttp_server_integration.py @@ -22,14 +22,54 @@ from opentelemetry import metrics as metrics_api from opentelemetry import trace as trace_api +from opentelemetry.instrumentation._semconv import ( + HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, + _server_active_requests_count_attrs_new, + _server_active_requests_count_attrs_old, + _server_duration_attrs_new, + _server_duration_attrs_old, + _StabilityMode, +) from opentelemetry.instrumentation.aiohttp_server import ( AioHttpServerInstrumentor, ) from opentelemetry.instrumentation.utils import suppress_http_instrumentation +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, +) from opentelemetry.semconv._incubating.attributes.http_attributes import ( + HTTP_FLAVOR, HTTP_METHOD, + HTTP_SCHEME, HTTP_STATUS_CODE, + HTTP_TARGET, HTTP_URL, + HTTP_USER_AGENT, +) +from opentelemetry.semconv._incubating.attributes.net_attributes import ( + NET_HOST_NAME, + NET_HOST_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_PATH, + URL_QUERY, + URL_SCHEME, +) +from opentelemetry.semconv.attributes.user_agent_attributes import ( + USER_AGENT_ORIGINAL, ) from opentelemetry.test.globals_test import ( reset_metrics_globals, @@ -164,10 +204,7 @@ async def test_status_code_instrumentation( assert expected_method.value == span.attributes[HTTP_METHOD] assert expected_status_code == span.attributes[HTTP_STATUS_CODE] - - assert ( - f"http://{server.host}:{server.port}{url}" == span.attributes[HTTP_URL] - ) + assert url == span.attributes[HTTP_TARGET] @pytest.mark.asyncio @@ -186,10 +223,16 @@ async def test_suppress_instrumentation( @pytest.mark.asyncio -async def test_remove_sensitive_params(tracer, aiohttp_server): +async def test_remove_sensitive_params(tracer, aiohttp_server, monkeypatch): """Test that sensitive information in URLs is properly redacted.""" _, memory_exporter = tracer + # Use old semconv to test HTTP_URL redaction + monkeypatch.setenv( + OTEL_SEMCONV_STABILITY_OPT_IN, _StabilityMode.DEFAULT.value + ) + _OpenTelemetrySemanticConventionStability._initialized = False + # Set up instrumentation AioHttpServerInstrumentor().instrument() @@ -228,6 +271,56 @@ async def handler(request): memory_exporter.clear() +@pytest.mark.asyncio +async def test_remove_sensitive_params_new( + tracer, aiohttp_server, monkeypatch +): + """Test URL handling with new semantic conventions (no redaction for URL_PATH/URL_QUERY).""" + _, memory_exporter = tracer + + # Use new semconv + monkeypatch.setenv( + OTEL_SEMCONV_STABILITY_OPT_IN, _StabilityMode.HTTP.value + ) + _OpenTelemetrySemanticConventionStability._initialized = False + + # Set up instrumentation + AioHttpServerInstrumentor().instrument() + + # Create app with test route + app = aiohttp.web.Application() + + async def handler(request): + return aiohttp.web.Response(text="hello") + + app.router.add_get("/status/200", handler) + + # Start the server + server = await aiohttp_server(app) + + # Make request with sensitive data in URL + url = f"http://username:password@{server.host}:{server.port}/status/200?Signature=secret" + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + assert response.status == 200 + assert await response.text() == "hello" + + # Verify span attributes with new semconv + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + + span = spans[0] + assert span.attributes[HTTP_REQUEST_METHOD] == "GET" + assert span.attributes[HTTP_RESPONSE_STATUS_CODE] == 200 + assert span.attributes[URL_PATH] == "/status/200" + assert span.attributes[URL_QUERY] == "Signature=REDACTED" + assert HTTP_URL not in span.attributes + + # Clean up + AioHttpServerInstrumentor().uninstrument() + memory_exporter.clear() + + def _get_sorted_metrics(metrics_data): resource_metrics = metrics_data.resource_metrics if metrics_data else [] @@ -400,3 +493,233 @@ async def handler(request): assert "http.response.header.custom_test_header_3" not in span.attributes AioHttpServerInstrumentor().uninstrument() + + +# pylint: disable=too-many-locals +@pytest.mark.asyncio +async def test_semantic_conventions_metrics_old_default( + tracer, meter, aiohttp_server, monkeypatch +): + _, memory_exporter = tracer + _, metrics_reader = meter + monkeypatch.setenv( + OTEL_SEMCONV_STABILITY_OPT_IN, _StabilityMode.DEFAULT.value + ) + _OpenTelemetrySemanticConventionStability._initialized = False + + AioHttpServerInstrumentor().instrument() + app = aiohttp.web.Application() + app.router.add_get("/test-path", default_handler) + server = await aiohttp_server(app) + client_session = aiohttp.ClientSession() + try: + url = f"http://{server.host}:{server.port}/test-path?query=test" + async with client_session.get( # pylint: disable=not-async-context-manager + url, headers={"User-Agent": "test-agent"} + ) as response: + assert response.status == 200 + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + + # Old semconv span attributes present + assert span.attributes.get(HTTP_METHOD) == "GET" + assert span.attributes.get(HTTP_SCHEME) == "http" + assert span.attributes.get(NET_HOST_NAME) == server.host + assert span.attributes.get(NET_HOST_PORT) == server.port + assert span.attributes.get(HTTP_TARGET) == "/test-path?query=test" + assert span.attributes.get(HTTP_USER_AGENT) == "test-agent" + assert span.attributes.get(HTTP_FLAVOR) == "1.1" + assert span.attributes.get(HTTP_STATUS_CODE) == 200 + # New semconv span attributes NOT present + assert HTTP_REQUEST_METHOD not in span.attributes + assert URL_SCHEME not in span.attributes + assert SERVER_ADDRESS not in span.attributes + assert SERVER_PORT not in span.attributes + assert URL_PATH not in span.attributes + assert URL_QUERY not in span.attributes + assert USER_AGENT_ORIGINAL not in span.attributes + assert NETWORK_PROTOCOL_VERSION not in span.attributes + assert HTTP_RESPONSE_STATUS_CODE not in span.attributes + + metrics = _get_sorted_metrics(metrics_reader.get_metrics_data()) + expected_metric_names = [ + "http.server.active_requests", + "http.server.duration", + ] + recommended_metrics_attrs = { + "http.server.active_requests": _server_active_requests_count_attrs_old, + "http.server.duration": _server_duration_attrs_old, + } + for metric in metrics: + assert metric.name in expected_metric_names + if metric.name == "http.server.duration": + assert metric.unit == "ms" + for point in metric.data.data_points: + for attr in point.attributes: + assert attr in recommended_metrics_attrs[metric.name] + + finally: + await client_session.close() + AioHttpServerInstrumentor().uninstrument() + + +# pylint: disable=too-many-locals +@pytest.mark.asyncio +async def test_semantic_conventions_metrics_new( + tracer, meter, aiohttp_server, monkeypatch +): + _, memory_exporter = tracer + _, metrics_reader = meter + monkeypatch.setenv( + OTEL_SEMCONV_STABILITY_OPT_IN, _StabilityMode.HTTP.value + ) + _OpenTelemetrySemanticConventionStability._initialized = False + + AioHttpServerInstrumentor().instrument() + app = aiohttp.web.Application() + app.router.add_get("/test-path", default_handler) + server = await aiohttp_server(app) + client_session = aiohttp.ClientSession() + try: + url = f"http://{server.host}:{server.port}/test-path?query=test" + async with client_session.get( # pylint: disable=not-async-context-manager + url, headers={"User-Agent": "test-agent"} + ) as response: + assert response.status == 200 + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + + # New semconv span attributes present + assert span.attributes.get(HTTP_REQUEST_METHOD) == "GET" + assert span.attributes.get(URL_SCHEME) == "http" + assert span.attributes.get(SERVER_ADDRESS) == server.host + assert span.attributes.get(SERVER_PORT) == server.port + assert span.attributes.get(URL_PATH) == "/test-path" + assert span.attributes.get(URL_QUERY) == "query=test" + assert span.attributes.get(USER_AGENT_ORIGINAL) == "test-agent" + assert span.attributes.get(NETWORK_PROTOCOL_VERSION) == "1.1" + assert span.attributes.get(HTTP_RESPONSE_STATUS_CODE) == 200 + # Old semconv span attributes NOT present + assert HTTP_METHOD not in span.attributes + assert HTTP_SCHEME not in span.attributes + assert NET_HOST_NAME not in span.attributes + assert NET_HOST_PORT not in span.attributes + assert HTTP_TARGET not in span.attributes + assert HTTP_USER_AGENT not in span.attributes + assert HTTP_FLAVOR not in span.attributes + assert HTTP_STATUS_CODE not in span.attributes + + metrics = _get_sorted_metrics(metrics_reader.get_metrics_data()) + expected_metric_names = [ + "http.server.active_requests", + "http.server.request.duration", + ] + recommended_metrics_attrs = { + "http.server.active_requests": _server_active_requests_count_attrs_new, + "http.server.request.duration": _server_duration_attrs_new, + } + for metric in metrics: + assert metric.name in expected_metric_names + if metric.name == "http.server.request.duration": + assert metric.unit == "s" + for point in metric.data.data_points: + if ( + isinstance(point, HistogramDataPoint) + and metric.name == "http.server.request.duration" + ): + assert ( + point.explicit_bounds + == HTTP_DURATION_HISTOGRAM_BUCKETS_NEW + ) + for attr in point.attributes: + assert attr in recommended_metrics_attrs[metric.name] + + finally: + await client_session.close() + AioHttpServerInstrumentor().uninstrument() + + +# pylint: disable=too-many-locals +@pytest.mark.asyncio +async def test_semantic_conventions_metrics_both( + tracer, meter, aiohttp_server, monkeypatch +): + _, memory_exporter = tracer + _, metrics_reader = meter + monkeypatch.setenv( + OTEL_SEMCONV_STABILITY_OPT_IN, _StabilityMode.HTTP_DUP.value + ) + _OpenTelemetrySemanticConventionStability._initialized = False + + AioHttpServerInstrumentor().instrument() + app = aiohttp.web.Application() + app.router.add_get("/test-path", default_handler) + server = await aiohttp_server(app) + client_session = aiohttp.ClientSession() + try: + url = f"http://{server.host}:{server.port}/test-path?query=test" + async with client_session.get( # pylint: disable=not-async-context-manager + url, headers={"User-Agent": "test-agent"} + ) as response: + assert response.status == 200 + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + + # Both old and new semconv span attributes present + assert span.attributes.get(HTTP_METHOD) == "GET" + assert span.attributes.get(HTTP_REQUEST_METHOD) == "GET" + assert span.attributes.get(HTTP_SCHEME) == "http" + assert span.attributes.get(URL_SCHEME) == "http" + assert span.attributes.get(NET_HOST_NAME) == server.host + assert span.attributes.get(SERVER_ADDRESS) == server.host + assert span.attributes.get(NET_HOST_PORT) == server.port + assert span.attributes.get(SERVER_PORT) == server.port + assert span.attributes.get(HTTP_TARGET) == "/test-path?query=test" + assert span.attributes.get(URL_PATH) == "/test-path" + assert span.attributes.get(URL_QUERY) == "query=test" + assert span.attributes.get(HTTP_USER_AGENT) == "test-agent" + assert span.attributes.get(USER_AGENT_ORIGINAL) == "test-agent" + assert span.attributes.get(HTTP_FLAVOR) == "1.1" + assert span.attributes.get(NETWORK_PROTOCOL_VERSION) == "1.1" + assert span.attributes.get(HTTP_STATUS_CODE) == 200 + assert span.attributes.get(HTTP_RESPONSE_STATUS_CODE) == 200 + + metrics = _get_sorted_metrics(metrics_reader.get_metrics_data()) + assert len(metrics) == 3 # Both duration metrics + active requests + server_active_requests_count_attrs_both = list( + _server_active_requests_count_attrs_old + ) + server_active_requests_count_attrs_both.extend( + _server_active_requests_count_attrs_new + ) + recommended_metrics_attrs = { + "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, + } + for metric in metrics: + if metric.unit == "ms": + assert metric.name == "http.server.duration" + elif metric.unit == "s": + assert metric.name == "http.server.request.duration" + else: + assert metric.name == "http.server.active_requests" + + for point in metric.data.data_points: + if ( + isinstance(point, HistogramDataPoint) + and metric.name == "http.server.request.duration" + ): + assert ( + point.explicit_bounds + == HTTP_DURATION_HISTOGRAM_BUCKETS_NEW + ) + for attr in point.attributes: + assert attr in recommended_metrics_attrs[metric.name] + + finally: + await client_session.close() + AioHttpServerInstrumentor().uninstrument()