From aa1adafed13702edc99e92743b7e5e210b6d0071 Mon Sep 17 00:00:00 2001 From: Tianning Li Date: Thu, 20 Aug 2026 15:02:20 -0400 Subject: [PATCH 1/2] feat(http_server): emit request-starting event --- .riot/requirements/1362718.txt | 20 ++++++ .riot/requirements/13ed954.txt | 20 ++++++ .riot/requirements/1638d0f.txt | 20 ++++++ .riot/requirements/180047f.txt | 25 +++++++ .riot/requirements/3cb8c3c.txt | 23 ++++++ .riot/requirements/43423a3.txt | 20 ++++++ ddtrace/_monkey.py | 2 + .../contrib/internal/http_server/__init__.py | 17 +++++ ddtrace/contrib/internal/http_server/patch.py | 71 +++++++++++++++++++ .../settings/_supported_configurations.py | 1 + docs/integrations.rst | 6 ++ riotfile.py | 8 +++ scripts/integration_registry/registry.yaml | 4 ++ scripts/supported_configurations.py | 6 ++ supported-configurations.json | 7 ++ tests/contrib/http_server/__init__.py | 0 .../http_server/test_http_server_patch.py | 26 +++++++ .../test_microvm_identity_refresh.py | 66 +++++++++++++++++ tests/contrib/suitespec.yml | 10 +++ 19 files changed, 352 insertions(+) create mode 100644 .riot/requirements/1362718.txt create mode 100644 .riot/requirements/13ed954.txt create mode 100644 .riot/requirements/1638d0f.txt create mode 100644 .riot/requirements/180047f.txt create mode 100644 .riot/requirements/3cb8c3c.txt create mode 100644 .riot/requirements/43423a3.txt create mode 100644 ddtrace/contrib/internal/http_server/__init__.py create mode 100644 ddtrace/contrib/internal/http_server/patch.py create mode 100644 tests/contrib/http_server/__init__.py create mode 100644 tests/contrib/http_server/test_http_server_patch.py create mode 100644 tests/contrib/http_server/test_microvm_identity_refresh.py 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..f9012404428 100644 --- a/ddtrace/_monkey.py +++ b/ddtrace/_monkey.py @@ -75,6 +75,7 @@ "aiopg": True, "aiobotocore": False, "httplib": False, + "http_server": True, "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..18d5bae76c0 --- /dev/null +++ b/ddtrace/contrib/internal/http_server/__init__.py @@ -0,0 +1,17 @@ +""" +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. Use +:ref:`ddtrace-run` or :ref:`import ddtrace.auto` to enable it, and +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..003fe14bcfc --- /dev/null +++ b/ddtrace/contrib/internal/http_server/patch.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import sys +from types import ModuleType +from typing import TYPE_CHECKING +from typing import Any +from typing import Callable + +from wrapt import wrap_function_wrapper as _w + +from ddtrace.contrib.internal.trace_utils import unwrap as _u +from ddtrace.internal import core + + +if TYPE_CHECKING: + import http.server + + +def _get_http_server() -> ModuleType: + # DEV: When patch() is called from the on-import hook, we're running from inside + # http.server's own exec_module(), before CPython's import machinery binds it as the + # "server" attribute of the "http" package (that setattr() happens only after + # exec_module() returns to _find_and_load()). Accessing it via `http.server` (attribute + # chain) at that point raises "cannot access submodule 'server' of module 'http' (most + # likely due to a circular import)". sys.modules is populated before exec_module() even + # starts, so prefer it -- falling back to a plain import for direct patch() calls that + # happen before anything has imported http.server yet (not in sys.modules at all). + loaded = sys.modules.get("http.server") + if loaded is not None: + return loaded + import http.server + + return http.server + + +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: + core.dispatch(core.WEB_REQUEST_STARTING, (instance.command, instance.path)) + return parsed + + +def patch() -> None: + http_server = _get_http_server() + 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: + http_server = _get_http_server() + 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/_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/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..d49b690e213 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": "true" + } + ], "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..eec9644f5ce --- /dev/null +++ b/tests/contrib/http_server/test_microvm_identity_refresh.py @@ -0,0 +1,66 @@ +import http.server +import io + +import mock +import pytest + +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.core.dispatch", wraps=core.dispatch) as m: + parsed = _handler_for("POST", REQUEST_STARTING_PATH).parse_request() + + assert parsed is True + m.assert_any_call(core.WEB_REQUEST_STARTING, ("POST", REQUEST_STARTING_PATH)) + + +def test_other_request(): + with 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 + m.assert_any_call(core.WEB_REQUEST_STARTING, ("GET", "/")) + + +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.core.dispatch", wraps=core.dispatch) as m: + parsed = handler.parse_request() + + assert parsed is False + assert not any(call.args[0] == core.WEB_REQUEST_STARTING 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' From 55febfa654873853cf8b4517af31af02368ad33e Mon Sep 17 00:00:00 2001 From: Tianning Li Date: Thu, 20 Aug 2026 17:08:04 -0400 Subject: [PATCH 2/2] fix(http_server): only auto patch in microvm environments --- ddtrace/_monkey.py | 2 +- .../contrib/internal/http_server/__init__.py | 7 ++-- tests/tracer/test_monkey.py | 35 +++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/ddtrace/_monkey.py b/ddtrace/_monkey.py index f9012404428..75d58cd990e 100644 --- a/ddtrace/_monkey.py +++ b/ddtrace/_monkey.py @@ -75,7 +75,7 @@ "aiopg": True, "aiobotocore": False, "httplib": False, - "http_server": True, + "http_server": bool(env.get("AWS_LAMBDA_MICROVM_IMAGE_ARN")), "urllib3": False, "vertexai": True, "vertica": True, diff --git a/ddtrace/contrib/internal/http_server/__init__.py b/ddtrace/contrib/internal/http_server/__init__.py index 18d5bae76c0..29b2c979a93 100644 --- a/ddtrace/contrib/internal/http_server/__init__.py +++ b/ddtrace/contrib/internal/http_server/__init__.py @@ -9,9 +9,10 @@ Enabling ~~~~~~~~ -The http_server integration is enabled by default. Use -:ref:`ddtrace-run` or :ref:`import ddtrace.auto` to enable it, and -disable it with ``DD_TRACE_HTTP_SERVER_ENABLED=false`` if needed:: +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/tests/tracer/test_monkey.py b/tests/tracer/test_monkey.py index 5631e7de1b0..c4189a88062 100644 --- a/tests/tracer/test_monkey.py +++ b/tests/tracer/test_monkey.py @@ -77,6 +77,41 @@ 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): + _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) + assert _monkey.PATCH_MODULES["http_server"] is False + assert "http_server" in _monkey._PATCHED_MODULES + @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()