diff --git a/.riot/requirements/1362718.txt b/.riot/requirements/1362718.txt new file mode 100644 index 00000000000..4c18cd862ab --- /dev/null +++ b/.riot/requirements/1362718.txt @@ -0,0 +1,20 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/1362718.in +# +attrs==26.1.0 +coverage[toml]==7.15.4 +hypothesis==6.45.0 +iniconfig==2.3.0 +mock==5.2.0 +opentracing==2.4.0 +packaging==26.3 +pluggy==1.6.0 +pygments==2.21.0 +pytest==9.1.1 +pytest-cov==7.1.0 +pytest-mock==3.15.1 +pytest-randomly==4.1.0 +sortedcontainers==2.4.0 diff --git a/.riot/requirements/13ed954.txt b/.riot/requirements/13ed954.txt new file mode 100644 index 00000000000..16c89155daf --- /dev/null +++ b/.riot/requirements/13ed954.txt @@ -0,0 +1,20 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/13ed954.in +# +attrs==26.1.0 +coverage[toml]==7.15.4 +hypothesis==6.45.0 +iniconfig==2.3.0 +mock==5.2.0 +opentracing==2.4.0 +packaging==26.3 +pluggy==1.6.0 +pygments==2.21.0 +pytest==9.1.1 +pytest-cov==7.1.0 +pytest-mock==3.15.1 +pytest-randomly==4.1.0 +sortedcontainers==2.4.0 diff --git a/.riot/requirements/1638d0f.txt b/.riot/requirements/1638d0f.txt new file mode 100644 index 00000000000..4cf4d9cc372 --- /dev/null +++ b/.riot/requirements/1638d0f.txt @@ -0,0 +1,20 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/1638d0f.in +# +attrs==26.1.0 +coverage[toml]==7.15.4 +hypothesis==6.45.0 +iniconfig==2.3.0 +mock==5.2.0 +opentracing==2.4.0 +packaging==26.3 +pluggy==1.6.0 +pygments==2.21.0 +pytest==9.1.1 +pytest-cov==7.1.0 +pytest-mock==3.15.1 +pytest-randomly==4.1.0 +sortedcontainers==2.4.0 diff --git a/.riot/requirements/180047f.txt b/.riot/requirements/180047f.txt new file mode 100644 index 00000000000..954fc0398ba --- /dev/null +++ b/.riot/requirements/180047f.txt @@ -0,0 +1,25 @@ +# +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/180047f.in +# +attrs==26.1.0 +coverage[toml]==7.10.7 +exceptiongroup==1.3.1 +hypothesis==6.45.0 +importlib-metadata==8.7.1 +iniconfig==2.1.0 +mock==5.2.0 +opentracing==2.4.0 +packaging==26.3 +pluggy==1.6.0 +pygments==2.21.0 +pytest==8.4.2 +pytest-cov==7.1.0 +pytest-mock==3.15.1 +pytest-randomly==4.0.1 +sortedcontainers==2.4.0 +tomli==2.4.1 +typing-extensions==4.16.0 +zipp==3.23.1 diff --git a/.riot/requirements/3cb8c3c.txt b/.riot/requirements/3cb8c3c.txt new file mode 100644 index 00000000000..95297a33249 --- /dev/null +++ b/.riot/requirements/3cb8c3c.txt @@ -0,0 +1,23 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/3cb8c3c.in +# +attrs==26.1.0 +coverage[toml]==7.15.4 +exceptiongroup==1.3.1 +hypothesis==6.45.0 +iniconfig==2.3.0 +mock==5.2.0 +opentracing==2.4.0 +packaging==26.3 +pluggy==1.6.0 +pygments==2.21.0 +pytest==9.1.1 +pytest-cov==7.1.0 +pytest-mock==3.15.1 +pytest-randomly==4.1.0 +sortedcontainers==2.4.0 +tomli==2.4.1 +typing-extensions==4.16.0 diff --git a/.riot/requirements/43423a3.txt b/.riot/requirements/43423a3.txt new file mode 100644 index 00000000000..e09b95353d4 --- /dev/null +++ b/.riot/requirements/43423a3.txt @@ -0,0 +1,20 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/43423a3.in +# +attrs==26.1.0 +coverage[toml]==7.15.4 +hypothesis==6.45.0 +iniconfig==2.3.0 +mock==5.2.0 +opentracing==2.4.0 +packaging==26.3 +pluggy==1.6.0 +pygments==2.21.0 +pytest==9.1.1 +pytest-cov==7.1.0 +pytest-mock==3.15.1 +pytest-randomly==4.1.0 +sortedcontainers==2.4.0 diff --git a/ddtrace/_monkey.py b/ddtrace/_monkey.py index ab5bc9dcd2d..a7b1e7c3c2f 100644 --- a/ddtrace/_monkey.py +++ b/ddtrace/_monkey.py @@ -75,6 +75,7 @@ "aiopg": True, "aiobotocore": False, "httplib": False, + "http_server": bool(env.get("AWS_LAMBDA_MICROVM_IMAGE_ARN", "").strip()), "urllib3": False, "vertexai": True, "vertica": True, @@ -165,6 +166,7 @@ "azure_functions": ("azure.functions",), "azure_servicebus": ("azure.servicebus",), "httplib": ("http.client",), + "http_server": ("http.server",), "kafka": ("confluent_kafka",), "google_adk": ("google.adk",), "google_cloud_pubsub": ("google.cloud.pubsub_v1",), diff --git a/ddtrace/contrib/internal/http_server/__init__.py b/ddtrace/contrib/internal/http_server/__init__.py new file mode 100644 index 00000000000..29b2c979a93 --- /dev/null +++ b/ddtrace/contrib/internal/http_server/__init__.py @@ -0,0 +1,18 @@ +""" +Patch the standard library ``http.server`` module (``BaseHTTPRequestHandler``). + +This integration does **not** create spans. Its only purpose is detecting the AWS Lambda +MicroVM ``/run`` lifecycle hook for applications that implement that hook with a raw +``http.server`` handler instead of a supported web framework. + + +Enabling +~~~~~~~~ + +The http_server integration is enabled by default only in AWS Lambda MicroVM +environments. Use :ref:`ddtrace-run` or :ref:`import ddtrace.auto` +to enable it there, or set ``DD_TRACE_HTTP_SERVER_ENABLED=true`` to force-enable it +elsewhere. Disable it with ``DD_TRACE_HTTP_SERVER_ENABLED=false`` if needed:: + + DD_TRACE_HTTP_SERVER_ENABLED=false ddtrace-run .... +""" diff --git a/ddtrace/contrib/internal/http_server/patch.py b/ddtrace/contrib/internal/http_server/patch.py new file mode 100644 index 00000000000..85bdd8d73bf --- /dev/null +++ b/ddtrace/contrib/internal/http_server/patch.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import http.server as http_server +from typing import Any +from typing import Callable + +from wrapt import wrap_function_wrapper as _w + +from ddtrace.contrib._events.web_framework import WebFrameworkEvents +from ddtrace.contrib.internal.trace_utils import unwrap as _u +from ddtrace.internal import core +from ddtrace.internal.serverless import in_aws_lambda_microvm + + +def get_version() -> str: + return "" + + +def _supported_versions() -> dict[str, str]: + return {"http.server": "*"} + + +def _wrap_parse_request( + wrapped: Callable[..., bool], + instance: http_server.BaseHTTPRequestHandler, + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> bool: + parsed = wrapped(*args, **kwargs) + if parsed and in_aws_lambda_microvm(): + core.dispatch(WebFrameworkEvents.WEB_REQUEST_STARTING.value, (instance.command, instance.path)) + return parsed + + +def patch() -> None: + if getattr(http_server, "__datadog_patch", False): + return + http_server.__datadog_patch = True # type: ignore[attr-defined] # patch marker, not a real module attr + + _w(http_server.BaseHTTPRequestHandler, "parse_request", _wrap_parse_request) + + +def unpatch() -> None: + if not getattr(http_server, "__datadog_patch", False): + return + http_server.__datadog_patch = False # type: ignore[attr-defined] # patch marker, not a real module attr + + _u(http_server.BaseHTTPRequestHandler, "parse_request") diff --git a/ddtrace/internal/settings/_config.py b/ddtrace/internal/settings/_config.py index 373a7b4a8f2..e848b9a94ec 100644 --- a/ddtrace/internal/settings/_config.py +++ b/ddtrace/internal/settings/_config.py @@ -167,6 +167,7 @@ "pylibmc", "httpx", "httplib", + "http_server", "rq", "jinja2", "aredis", diff --git a/ddtrace/internal/settings/_supported_configurations.py b/ddtrace/internal/settings/_supported_configurations.py index 45e5894e35b..ada47e3dd19 100644 --- a/ddtrace/internal/settings/_supported_configurations.py +++ b/ddtrace/internal/settings/_supported_configurations.py @@ -577,6 +577,7 @@ "DD_TRACE_HTTPLIB_ENABLED", "DD_TRACE_HTTPX_ENABLED", "DD_TRACE_HTTP_CLIENT_TAG_QUERY_STRING", + "DD_TRACE_HTTP_SERVER_ENABLED", "DD_TRACE_HTTP_SERVER_ERROR_STATUSES", "DD_TRACE_INFERRED_PROXY_SERVICES_ENABLED", "DD_TRACE_JINJA2_ENABLED", diff --git a/docs/index.rst b/docs/index.rst index 88a201854f2..560e1d6fd08 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -126,6 +126,8 @@ contacting support. +--------------------------------------------------+---------------+----------------+ | :ref:`gunicorn ` | >= 20.0.04 | No | +--------------------------------------------------+---------------+----------------+ +| :ref:`http_server ` | \* | Yes [7]_ | ++--------------------------------------------------+---------------+----------------+ | :ref:`httplib` | \* | Yes | +--------------------------------------------------+---------------+----------------+ | :ref:`httpx` | >= 0.25 | Yes | @@ -255,6 +257,8 @@ contacting support. .. [6] ``protobuf`` integration is enabled if ``config._data_streams_enabled or [DD_DATA_STREAMS_ENABLED]`` is ``True``. +.. [7] ``http_server`` integration is automatically enabled only in AWS Lambda MicroVM environments. + .. _`Instrumentation Telemetry`: Instrumentation Telemetry diff --git a/docs/integrations.rst b/docs/integrations.rst index dfe93bbe59e..625db53ae81 100644 --- a/docs/integrations.rst +++ b/docs/integrations.rst @@ -299,6 +299,12 @@ gunicorn .. automodule:: ddtrace.contrib.internal.gunicorn +.. _http_server: + +http_server +^^^^^^^^^^^ +.. automodule:: ddtrace.contrib.internal.http_server + .. _httplib: httplib diff --git a/riotfile.py b/riotfile.py index 10c3c9e6eaf..066b0a7e3a7 100644 --- a/riotfile.py +++ b/riotfile.py @@ -838,6 +838,14 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT }, pys=select_pys(), ), + Venv( + name="http_server", + command="pytest {cmdargs} tests/contrib/http_server", + pkgs={ + "pytest-randomly": latest, + }, + pys=select_pys(), + ), Venv( name="logging", command="pytest -n auto --dist=worksteal {cmdargs} tests/contrib/logging", diff --git a/scripts/integration_registry/registry.yaml b/scripts/integration_registry/registry.yaml index 0e35681ee07..fc0af2b4524 100644 --- a/scripts/integration_registry/registry.yaml +++ b/scripts/integration_registry/registry.yaml @@ -495,6 +495,10 @@ integrations: min: 20.0.4 max: 23.0.0 +- integration_name: http_server + is_external_package: false + is_tested: true + - integration_name: httplib is_external_package: false is_tested: true diff --git a/scripts/supported_configurations.py b/scripts/supported_configurations.py index d79104905f3..03833c391f0 100644 --- a/scripts/supported_configurations.py +++ b/scripts/supported_configurations.py @@ -33,6 +33,10 @@ # Do not add new values to this list; ignoring a value prevents it from working with the configuration system. IGNORED_ENVIRONMENT_VARIABLES = ["_DD_CONTEXTVAR"] +# Integrations that are patchable via DD_TRACE_{NAME}_ENABLED but do not create spans, +# so they do not have DD_{NAME}_SERVICE[_NAME] configuration. +NO_SERVICE_CONFIGURATION = {"http_server"} + HEADER = """\ # AUTO-GENERATED from supported-configurations.json — do not edit manually. # Run: python scripts/supported_configurations.py @@ -423,6 +427,8 @@ def check_registry(data: dict) -> int: n = name.upper() if name not in not_patchable and f"DD_TRACE_{n}_ENABLED" not in all_known: missing.add(f"DD_TRACE_{n}_ENABLED") + if name in NO_SERVICE_CONFIGURATION: + continue for var in (f"DD_{n}_SERVICE", f"DD_{n}_SERVICE_NAME"): if var not in all_known: missing.add(var) diff --git a/supported-configurations.json b/supported-configurations.json index 144a579eeb0..1355f463181 100644 --- a/supported-configurations.json +++ b/supported-configurations.json @@ -4360,6 +4360,13 @@ "default": "true" } ], + "DD_TRACE_HTTP_SERVER_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": null + } + ], "DD_TRACE_HTTP_SERVER_ERROR_STATUSES": [ { "implementation": "A", diff --git a/tests/contrib/http_server/__init__.py b/tests/contrib/http_server/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/contrib/http_server/test_http_server_patch.py b/tests/contrib/http_server/test_http_server_patch.py new file mode 100644 index 00000000000..afe6df9ec51 --- /dev/null +++ b/tests/contrib/http_server/test_http_server_patch.py @@ -0,0 +1,26 @@ +from ddtrace.contrib.internal.http_server.patch import get_version +from ddtrace.contrib.internal.http_server.patch import patch +from ddtrace.contrib.internal.http_server.patch import unpatch +from tests.contrib.patch import PatchTestCase + + +class TestHttpServerPatch(PatchTestCase.Base): + __integration_name__ = "http_server" + __module_name__ = "http.server" + __patch_func__ = patch + __unpatch_func__ = unpatch + __get_version__ = get_version + + def assert_module_patched(self, http_server): + self.assert_wrapped(http_server.BaseHTTPRequestHandler.parse_request) + + def assert_not_module_patched(self, http_server): + self.assert_not_wrapped(http_server.BaseHTTPRequestHandler.parse_request) + + def assert_not_module_double_patched(self, http_server): + self.assert_not_double_wrapped(http_server.BaseHTTPRequestHandler.parse_request) + + def test_and_emit_get_version(self): + version = get_version() + assert isinstance(version, str) + assert version == "" diff --git a/tests/contrib/http_server/test_microvm_identity_refresh.py b/tests/contrib/http_server/test_microvm_identity_refresh.py new file mode 100644 index 00000000000..cfa6cebef81 --- /dev/null +++ b/tests/contrib/http_server/test_microvm_identity_refresh.py @@ -0,0 +1,76 @@ +import http.server +import io + +import mock +import pytest + +from ddtrace.contrib._events.web_framework import WebFrameworkEvents +from ddtrace.contrib.internal.http_server.patch import patch +from ddtrace.contrib.internal.http_server.patch import unpatch +from ddtrace.internal import core + + +REQUEST_STARTING_PATH = "/web-request-starting" + + +def _handler_for(method, path): + """Build a BaseHTTPRequestHandler with just enough state for parse_request() to run, + bypassing the real socket/handle() loop (which would also dispatch to do_GET/do_POST). + """ + handler = http.server.BaseHTTPRequestHandler.__new__(http.server.BaseHTTPRequestHandler) + handler.raw_requestline = f"{method} {path} HTTP/1.1\r\n".encode() + handler.rfile = io.BytesIO(b"Host: localhost\r\n\r\n") + return handler + + +@pytest.fixture(autouse=True) +def _patched(): + patch() + yield + unpatch() + + +def test_microvm_run_hook_request(): + """parse_request() must dispatch method/path before request tracing starts. + + This covers apps that implement the hook with a raw http.server handler instead of a + supported web framework. + """ + with ( + mock.patch("ddtrace.contrib.internal.http_server.patch.in_aws_lambda_microvm", return_value=True), + mock.patch("ddtrace.contrib.internal.http_server.patch.core.dispatch", wraps=core.dispatch) as m, + ): + parsed = _handler_for("POST", REQUEST_STARTING_PATH).parse_request() + + assert parsed is True + m.assert_any_call(WebFrameworkEvents.WEB_REQUEST_STARTING.value, ("POST", REQUEST_STARTING_PATH)) + + +def test_other_request_does_not_dispatch_outside_microvm(): + with ( + mock.patch("ddtrace.contrib.internal.http_server.patch.in_aws_lambda_microvm", return_value=False), + mock.patch("ddtrace.contrib.internal.http_server.patch.core.dispatch", wraps=core.dispatch) as m, + ): + parsed = _handler_for("GET", "/").parse_request() + + assert parsed is True + assert not any(call.args[0] == WebFrameworkEvents.WEB_REQUEST_STARTING.value for call in m.call_args_list) + + +def test_malformed_request_does_not_refresh(): + """A request line parse_request() can't parse must not emit the pre-request event. + + There is no method/path to report. + """ + handler = http.server.BaseHTTPRequestHandler.__new__(http.server.BaseHTTPRequestHandler) + handler.raw_requestline = b"" + handler.rfile = io.BytesIO(b"") + + with ( + mock.patch("ddtrace.contrib.internal.http_server.patch.in_aws_lambda_microvm", return_value=True), + mock.patch("ddtrace.contrib.internal.http_server.patch.core.dispatch", wraps=core.dispatch) as m, + ): + parsed = handler.parse_request() + + assert parsed is False + assert not any(call.args[0] == WebFrameworkEvents.WEB_REQUEST_STARTING.value for call in m.call_args_list) diff --git a/tests/contrib/suitespec.yml b/tests/contrib/suitespec.yml index 6d724daafa4..23ec6ad511e 100644 --- a/tests/contrib/suitespec.yml +++ b/tests/contrib/suitespec.yml @@ -116,6 +116,8 @@ components: - ddtrace/contrib/internal/grpc/* gunicorn: - ddtrace/contrib/internal/gunicorn/* + http_server: + - ddtrace/contrib/internal/http_server/* httplib: - ddtrace/contrib/internal/httplib/* httpx: @@ -871,6 +873,14 @@ suites: - tests/contrib/gunicorn/* - tests/snapshots/tests.contrib.gunicorn.* snapshot: true + http_server: + paths: + - '@bootstrap' + - '@core' + - '@contrib' + - '@tracing' + - '@http_server' + - tests/contrib/http_server/* httplib: paths: - '@bootstrap' diff --git a/tests/tracer/test_monkey.py b/tests/tracer/test_monkey.py index 5631e7de1b0..5bdf894ed1e 100644 --- a/tests/tracer/test_monkey.py +++ b/tests/tracer/test_monkey.py @@ -2,6 +2,9 @@ import unittest.mock from ddtrace import _monkey +from ddtrace import config +from ddtrace.internal.compat import is_wrapted +from ddtrace.internal.wrapping import is_wrapped from tests.subprocesstest import SubprocessTestCase from tests.subprocesstest import run_in_subprocess @@ -77,6 +80,52 @@ def test_patch_all_env_override_httplib_enabled(self): _monkey._patch_all() assert "httplib" in _monkey._PATCHED_MODULES + @run_in_subprocess(env_overrides=dict(AWS_LAMBDA_MICROVM_IMAGE_ARN="")) + def test_patch_all_http_server_disabled_outside_microvm(self): + assert config.http_server + _monkey._patch_all() + assert _monkey.PATCH_MODULES["http_server"] is False + assert "http_server" not in _monkey._PATCHED_MODULES + + @run_in_subprocess(env_overrides=dict(AWS_LAMBDA_MICROVM_IMAGE_ARN=" ")) + def test_patch_all_http_server_disabled_with_blank_microvm_arn(self): + _monkey._patch_all() + assert _monkey.PATCH_MODULES["http_server"] is False + assert "http_server" not in _monkey._PATCHED_MODULES + + @run_in_subprocess(env_overrides=dict(AWS_LAMBDA_MICROVM_IMAGE_ARN="arn:aws:lambda:microvm-image:test")) + def test_patch_all_http_server_enabled_in_microvm(self): + _monkey._patch_all() + assert _monkey.PATCH_MODULES["http_server"] is True + assert "http_server" in _monkey._PATCHED_MODULES + + @run_in_subprocess( + env_overrides=dict( + AWS_LAMBDA_MICROVM_IMAGE_ARN="arn:aws:lambda:microvm-image:test", + DD_TRACE_HTTP_SERVER_ENABLED="false", + ) + ) + def test_patch_all_http_server_env_override_disabled_in_microvm(self): + _monkey._patch_all() + assert _monkey.PATCH_MODULES["http_server"] is True + assert "http_server" not in _monkey._PATCHED_MODULES + + @run_in_subprocess(env_overrides=dict(AWS_LAMBDA_MICROVM_IMAGE_ARN="", DD_TRACE_HTTP_SERVER_ENABLED="true")) + def test_patch_all_http_server_env_override_enabled_outside_microvm(self): + _monkey._patch_all() + assert _monkey.PATCH_MODULES["http_server"] is False + assert "http_server" in _monkey._PATCHED_MODULES + + @run_in_subprocess(env_overrides=dict(AWS_LAMBDA_MICROVM_IMAGE_ARN="")) + def test_patch_http_server_manual_patch_enabled_outside_microvm(self): + _monkey.patch(http_server=True) + http_server = __import__("http.server", fromlist=["BaseHTTPRequestHandler"]) + assert _monkey.PATCH_MODULES["http_server"] is False + assert "http_server" in _monkey._PATCHED_MODULES + assert is_wrapted(http_server.BaseHTTPRequestHandler.parse_request) or is_wrapped( + http_server.BaseHTTPRequestHandler.parse_request + ) + @run_in_subprocess(env_overrides=dict(DD_MODEL_LAB_ENABLED="true")) def test_patch_all_env_override_model_lab_enables_mlflow(self): _monkey._patch_all()