From 1c3cc276ee4e5c4948129cc5361b19b7e3d499fc Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Thu, 13 Nov 2025 11:46:14 -0800 Subject: [PATCH 1/4] Correct code for Sanic instrumentation --- newrelic/hooks/framework_sanic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/newrelic/hooks/framework_sanic.py b/newrelic/hooks/framework_sanic.py index 14077eb6d9..74d8ab678e 100644 --- a/newrelic/hooks/framework_sanic.py +++ b/newrelic/hooks/framework_sanic.py @@ -183,7 +183,7 @@ async def _nr_sanic_response_send(wrapped, instance, args, kwargs): transaction = current_transaction() result = wrapped(*args, **kwargs) if isawaitable(result): - await result + result = await result if transaction is None: return result From 8a3a5ec6416b4a66b72dca17d2fc436f589930f5 Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Thu, 13 Nov 2025 11:36:48 -0800 Subject: [PATCH 2/4] Correct handling of headers in ASGIWebTransaction --- newrelic/api/asgi_application.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/newrelic/api/asgi_application.py b/newrelic/api/asgi_application.py index 669d3e6db5..c642573fbb 100644 --- a/newrelic/api/asgi_application.py +++ b/newrelic/api/asgi_application.py @@ -232,7 +232,13 @@ async def send(self, event): finally: self.__exit__(*sys.exc_info()) elif event["type"] == "http.response.start": - self.process_response(event["status"], event.get("headers", ())) + # event["headers"] may be a generator, and consuming it via process_response will leave the original + # ASGI application with no headers. Fix this by preserving them in a list before consuming them. + if "headers" in event: + event["headers"] = headers = list(event["headers"]) + else: + headers = [] + self.process_response(event["status"], headers) return await self._send(event) From bb26efa69687640c5e582872614950d3c5b1ccfe Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Thu, 13 Nov 2025 12:35:44 -0800 Subject: [PATCH 3/4] Correct handling of headers in ASGIBrowserMiddleware --- newrelic/api/asgi_application.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/newrelic/api/asgi_application.py b/newrelic/api/asgi_application.py index c642573fbb..6b9a31130e 100644 --- a/newrelic/api/asgi_application.py +++ b/newrelic/api/asgi_application.py @@ -132,10 +132,20 @@ async def send_inject_browser_agent(self, message): message_type = message["type"] if message_type == "http.response.start" and not self.initial_message: - headers = list(message.get("headers", ())) + # message["headers"] may be a generator, and consuming it via process_response will leave the original + # application with no headers. Fix this by preserving them in a list before consuming them. + if "headers" in message: + message["headers"] = headers = list(message["headers"]) + else: + headers = [] + + # Check if we should insert the HTML snippet based on the headers. + # Currently if there are no headers this will always be False, but call the function + # anyway in case this logic changes in the future. if not self.should_insert_html(headers): await self.abort() return + message["headers"] = headers self.initial_message = message elif message_type == "http.response.body" and self.initial_message: From 247e7fa345e2d6fd36224d3c0e1d6f5d20f3e1e6 Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Thu, 13 Nov 2025 12:35:29 -0800 Subject: [PATCH 4/4] Add regression test for ASGI headers issues --- tests/agent_features/test_asgi_transaction.py | 24 +++++++++++++++++++ tests/testing_support/asgi_testing.py | 2 +- .../sample_asgi_applications.py | 17 +++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/agent_features/test_asgi_transaction.py b/tests/agent_features/test_asgi_transaction.py index e70ec95901..ac774689bd 100644 --- a/tests/agent_features/test_asgi_transaction.py +++ b/tests/agent_features/test_asgi_transaction.py @@ -19,6 +19,7 @@ from testing_support.fixtures import override_application_settings from testing_support.sample_asgi_applications import ( AppWithDescriptor, + asgi_application_generator_headers, simple_app_v2, simple_app_v2_init_exc, simple_app_v2_raw, @@ -37,6 +38,7 @@ simple_app_v3_wrapped = AsgiTest(simple_app_v3) simple_app_v2_wrapped = AsgiTest(simple_app_v2) simple_app_v2_init_exc = AsgiTest(simple_app_v2_init_exc) +asgi_application_generator_headers = AsgiTest(asgi_application_generator_headers) # Test naming scheme logic and ASGIApplicationWrapper for a single callable @@ -85,6 +87,28 @@ def test_double_callable_raw(): assert response.body == b"" +# Ensure headers object is preserved +@pytest.mark.parametrize("browser_monitoring", [True, False]) +@validate_transaction_metrics(name="", group="Uri") +def test_generator_headers(browser_monitoring): + """ + Both ASGIApplicationWrapper and ASGIBrowserMiddleware can cause headers to be lost if generators are + not handled properly. + + Ensure neither destroys headers by testing with and without the ASGIBrowserMiddleware, to make sure whichever + receives headers first properly preserves them in a list. + """ + + @override_application_settings({"browser_monitoring.enabled": browser_monitoring}) + def _test(): + response = asgi_application_generator_headers.make_request("GET", "/") + assert response.status == 200 + assert response.headers == {"x-my-header": "myvalue"} + assert response.body == b"" + + _test() + + # Test asgi_application decorator with parameters passed in on a single callable @pytest.mark.parametrize("name, group", ((None, "group"), ("name", "group"), ("", "group"))) def test_asgi_application_decorator_single_callable(name, group): diff --git a/tests/testing_support/asgi_testing.py b/tests/testing_support/asgi_testing.py index 821a20fe96..5c97be8860 100644 --- a/tests/testing_support/asgi_testing.py +++ b/tests/testing_support/asgi_testing.py @@ -106,7 +106,7 @@ def process_output(self): if self.response_state is ResponseState.NOT_STARTED: assert message["type"] == "http.response.start" response_status = message["status"] - response_headers = message.get("headers", response_headers) + response_headers = list(message.get("headers", response_headers)) self.response_state = ResponseState.BODY elif self.response_state is ResponseState.BODY: assert message["type"] == "http.response.body" diff --git a/tests/testing_support/sample_asgi_applications.py b/tests/testing_support/sample_asgi_applications.py index c1ef860763..e281a7cbf2 100644 --- a/tests/testing_support/sample_asgi_applications.py +++ b/tests/testing_support/sample_asgi_applications.py @@ -114,6 +114,23 @@ async def normal_asgi_application(scope, receive, send): await send({"type": "http.response.body", "body": output}) +@ASGIApplicationWrapper +async def asgi_application_generator_headers(scope, receive, send): + if scope["type"] == "lifespan": + return await handle_lifespan(scope, receive, send) + + if scope["type"] != "http": + raise ValueError("unsupported") + + def headers(): + yield (b"x-my-header", b"myvalue") + + await send({"type": "http.response.start", "status": 200, "headers": headers()}) + await send({"type": "http.response.body"}) + + assert current_transaction() is None + + async def handle_lifespan(scope, receive, send): """Handle lifespan protocol with no-ops to allow more compatibility.""" while True: