diff --git a/newrelic/config.py b/newrelic/config.py index 1c3571a546..a447e20bfc 100644 --- a/newrelic/config.py +++ b/newrelic/config.py @@ -2540,6 +2540,8 @@ def _process_module_builtin_defaults(): _process_module_definition("uvicorn.config", "newrelic.hooks.adapter_uvicorn", "instrument_uvicorn_config") + _process_module_definition("daphne.server", "newrelic.hooks.adapter_daphne", "instrument_daphne_server") + _process_module_definition("sanic.app", "newrelic.hooks.framework_sanic", "instrument_sanic_app") _process_module_definition("sanic.response", "newrelic.hooks.framework_sanic", "instrument_sanic_response") @@ -2712,7 +2714,9 @@ def _process_module_builtin_defaults(): ) _process_module_definition( - "redis.commands.timeseries.commands", "newrelic.hooks.datastore_redis", "instrument_redis_commands_timeseries_commands" + "redis.commands.timeseries.commands", + "newrelic.hooks.datastore_redis", + "instrument_redis_commands_timeseries_commands", ) _process_module_definition( diff --git a/newrelic/core/environment.py b/newrelic/core/environment.py index f63047ab5e..17b03813c0 100644 --- a/newrelic/core/environment.py +++ b/newrelic/core/environment.py @@ -170,6 +170,13 @@ def environment_settings(): if hasattr(uvicorn, "__version__"): dispatcher.append(("Dispatcher Version", uvicorn.__version__)) + if not dispatcher and "daphne" in sys.modules: + dispatcher.append(("Dispatcher", "daphne")) + daphne = sys.modules["daphne"] + + if hasattr(daphne, "__version__"): + dispatcher.append(("Dispatcher Version", daphne.__version__)) + if not dispatcher and "tornado" in sys.modules: dispatcher.append(("Dispatcher", "tornado")) tornado = sys.modules["tornado"] diff --git a/newrelic/hooks/adapter_daphne.py b/newrelic/hooks/adapter_daphne.py new file mode 100644 index 0000000000..430d9c4b3c --- /dev/null +++ b/newrelic/hooks/adapter_daphne.py @@ -0,0 +1,33 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from newrelic.api.asgi_application import ASGIApplicationWrapper + + +@property +def application(self): + return getattr(self, "_nr_application", vars(self).get("application", None)) + + +@application.setter +def application(self, value): + # Wrap app only once + if value and not getattr(value, "_nr_wrapped", False): + value = ASGIApplicationWrapper(value) + value._nr_wrapped = True + self._nr_application = value + + +def instrument_daphne_server(module): + module.Server.application = application diff --git a/tests/adapter_daphne/conftest.py b/tests/adapter_daphne/conftest.py new file mode 100644 index 0000000000..cda62f22e1 --- /dev/null +++ b/tests/adapter_daphne/conftest.py @@ -0,0 +1,37 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from testing_support.fixtures import ( # noqa: F401; pylint: disable=W0611 + code_coverage_fixture, + collector_agent_registration_fixture, + collector_available_fixture, +) + +_coverage_source = [ + "newrelic.hooks.adapter_daphne", +] + +code_coverage = code_coverage_fixture(source=_coverage_source) + +_default_settings = { + "transaction_tracer.explain_threshold": 0.0, + "transaction_tracer.transaction_threshold": 0.0, + "transaction_tracer.stack_trace_threshold": 0.0, + "debug.log_data_collector_payloads": True, + "debug.record_transaction_failure": True, +} + +collector_agent_registration = collector_agent_registration_fixture( + app_name="Python Agent Test (adapter_daphne)", default_settings=_default_settings +) diff --git a/tests/adapter_daphne/test_daphne.py b/tests/adapter_daphne/test_daphne.py new file mode 100644 index 0000000000..4953e9a9ff --- /dev/null +++ b/tests/adapter_daphne/test_daphne.py @@ -0,0 +1,136 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import threading +from urllib.request import HTTPError, urlopen + +import daphne.server +import pytest +from testing_support.fixtures import ( + override_application_settings, + raise_background_exceptions, + validate_transaction_errors, + validate_transaction_metrics, + wait_for_background_threads, +) +from testing_support.sample_asgi_applications import ( + AppWithCall, + AppWithCallRaw, + simple_app_v2_raw, +) +from testing_support.util import get_open_port + +from newrelic.common.object_names import callable_name + +DAPHNE_VERSION = tuple(int(v) for v in daphne.__version__.split(".")[:2]) +skip_asgi_3_unsupported = pytest.mark.skipif(DAPHNE_VERSION < (3, 0), reason="ASGI3 unsupported") +skip_asgi_2_unsupported = pytest.mark.skipif(DAPHNE_VERSION >= (3, 0), reason="ASGI2 unsupported") + + +@pytest.fixture( + params=( + pytest.param( + simple_app_v2_raw, + marks=skip_asgi_2_unsupported, + ), + pytest.param( + AppWithCallRaw(), + marks=skip_asgi_3_unsupported, + ), + pytest.param( + AppWithCall(), + marks=skip_asgi_3_unsupported, + ), + ), + ids=("raw", "class_with_call", "class_with_call_double_wrapped"), +) +def app(request, server_and_port): + app = request.param + server, _ = server_and_port + server.application = app + return app + + +@pytest.fixture(scope="session") +def port(server_and_port): + _, port = server_and_port + return port + + +@pytest.fixture(scope="session") +def server_and_port(): + port = get_open_port() + + servers = [] + loops = [] + ready = threading.Event() + + def server_run(): + def on_ready(): + if not ready.is_set(): + loops.append(asyncio.get_event_loop()) + servers.append(server) + ready.set() + + async def fake_app(*args, **kwargs): + raise RuntimeError("Failed to swap out app.") + + server = daphne.server.Server( + fake_app, + endpoints=["tcp:%d:interface=127.0.0.1" % port], + ready_callable=on_ready, + signal_handlers=False, + verbosity=9, + ) + + server.run() + + thread = threading.Thread(target=server_run, daemon=True) + thread.start() + assert ready.wait(timeout=10) + yield servers[0], port + + reactor = daphne.server.reactor + _ = [loop.call_soon_threadsafe(reactor.stop) for loop in loops] # Stop all loops + thread.join(timeout=10) + + if thread.is_alive(): + raise RuntimeError("Thread failed to exit in time.") + + +@override_application_settings({"transaction_name.naming_scheme": "framework"}) +def test_daphne_200(port, app): + @validate_transaction_metrics(callable_name(app)) + @raise_background_exceptions() + @wait_for_background_threads() + def response(): + return urlopen("http://localhost:%d" % port, timeout=10) + + assert response().status == 200 + + +@override_application_settings({"transaction_name.naming_scheme": "framework"}) +@validate_transaction_errors(["builtins:ValueError"]) +def test_daphne_500(port, app): + @validate_transaction_metrics(callable_name(app)) + @raise_background_exceptions() + @wait_for_background_threads() + def _test(): + try: + urlopen("http://localhost:%d/exc" % port) + except HTTPError: + pass + + _test() diff --git a/tests/adapter_uvicorn/test_uvicorn.py b/tests/adapter_uvicorn/test_uvicorn.py index c93e719e84..e3261f4e87 100644 --- a/tests/adapter_uvicorn/test_uvicorn.py +++ b/tests/adapter_uvicorn/test_uvicorn.py @@ -97,7 +97,7 @@ async def on_tick(): thread = threading.Thread(target=server_run, daemon=True) thread.start() - ready.wait() + assert ready.wait(timeout=10) yield port _ = [loop.stop() for loop in loops] # Stop all loops thread.join(timeout=1) diff --git a/tox.ini b/tox.ini index 2f7ee4bcce..d054034d2f 100644 --- a/tox.ini +++ b/tox.ini @@ -43,6 +43,8 @@ setupdir = {toxinidir} envlist = python-adapter_cheroot-{py27,py37,py38,py39,py310}, + python-adapter_daphne-{py37,py38,py39,py310}-daphnelatest, + python-adapter_daphne-py38-daphne{0204,0205}, python-adapter_gevent-{py27,py37,py38,py310}, python-adapter_gunicorn-{py37,py38,py39,py310}-aiohttp3-gunicornlatest, python-adapter_uvicorn-py37-uvicorn03, @@ -163,6 +165,9 @@ deps = # Test Suite Dependencies adapter_cheroot: cheroot + adapter_daphne-daphnelatest: daphne + adapter_daphne-daphne0205: daphne<2.6 + adapter_daphne-daphne0204: daphne<2.5 adapter_gevent: WSGIProxy2 adapter_gevent: gevent adapter_gevent: urllib3 @@ -372,6 +377,7 @@ extras = changedir = adapter_cheroot: tests/adapter_cheroot + adapter_daphne: tests/adapter_daphne adapter_gevent: tests/adapter_gevent adapter_gunicorn: tests/adapter_gunicorn adapter_uvicorn: tests/adapter_uvicorn