Skip to content

Commit d0896ae

Browse files
TimPansinolrafeeihmstepanekumaannamalai
authored
Hypercorn ASGI Server Instrumentation (#598)
* Hypercorn instrumentation * Fix hypercorn ASGI2/3 detection * Add hypercorn to tox * Hypercorn testing * Fix flake8 errors * Apply linter fixes * Fix lifespan support for hypercorn 0.10 * More explicit timeout errors. * [Mega-Linter] Apply linters fixes * Bump tests * Add ignored txn endpoints to sample apps Co-authored-by: Lalleh Rafeei <[email protected]> Co-authored-by: Hannah Stepanek <[email protected]> Co-authored-by: Uma Annamalai <[email protected]> * Fix ASGI sample app transaction assertions * [Mega-Linter] Apply linters fixes * Bump Tests * Fix issues from code review * Fix testing for hypercorn after asgi2 removal * Add hypercorn WSGI instrumentation * Fix exact patch version for hypercorn updates * Formatting Co-authored-by: TimPansino <[email protected]> Co-authored-by: Lalleh Rafeei <[email protected]> Co-authored-by: Hannah Stepanek <[email protected]> Co-authored-by: Uma Annamalai <[email protected]>
1 parent 9040fbd commit d0896ae

File tree

7 files changed

+345
-21
lines changed

7 files changed

+345
-21
lines changed

newrelic/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2540,6 +2540,14 @@ def _process_module_builtin_defaults():
25402540

25412541
_process_module_definition("uvicorn.config", "newrelic.hooks.adapter_uvicorn", "instrument_uvicorn_config")
25422542

2543+
_process_module_definition(
2544+
"hypercorn.asyncio.run", "newrelic.hooks.adapter_hypercorn", "instrument_hypercorn_asyncio_run"
2545+
)
2546+
_process_module_definition(
2547+
"hypercorn.trio.run", "newrelic.hooks.adapter_hypercorn", "instrument_hypercorn_trio_run"
2548+
)
2549+
_process_module_definition("hypercorn.utils", "newrelic.hooks.adapter_hypercorn", "instrument_hypercorn_utils")
2550+
25432551
_process_module_definition("daphne.server", "newrelic.hooks.adapter_daphne", "instrument_daphne_server")
25442552

25452553
_process_module_definition("sanic.app", "newrelic.hooks.framework_sanic", "instrument_sanic_app")

newrelic/core/environment.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@
4343
def environment_settings():
4444
"""Returns an array of arrays of environment settings"""
4545

46+
# Find version resolver.
47+
48+
get_version = None
49+
# importlib was introduced into the standard library starting in Python3.8.
50+
if "importlib" in sys.modules and hasattr(sys.modules["importlib"], "metadata"):
51+
get_version = sys.modules["importlib"].metadata.version
52+
elif "pkg_resources" in sys.modules:
53+
54+
def get_version(name): # pylint: disable=function-redefined
55+
return sys.modules["pkg_resources"].get_distribution(name).version
56+
4657
env = []
4758

4859
# Agent information.
@@ -104,6 +115,8 @@ def environment_settings():
104115

105116
dispatcher = []
106117

118+
# Find the first dispatcher module that's been loaded and report that as the dispatcher.
119+
# If possible, also report the dispatcher's version and any other environment information.
107120
if not dispatcher and "mod_wsgi" in sys.modules:
108121
mod_wsgi = sys.modules["mod_wsgi"]
109122
if hasattr(mod_wsgi, "process_group"):
@@ -170,6 +183,18 @@ def environment_settings():
170183
if hasattr(uvicorn, "__version__"):
171184
dispatcher.append(("Dispatcher Version", uvicorn.__version__))
172185

186+
if not dispatcher and "hypercorn" in sys.modules:
187+
dispatcher.append(("Dispatcher", "hypercorn"))
188+
hypercorn = sys.modules["hypercorn"]
189+
190+
if hasattr(hypercorn, "__version__"):
191+
dispatcher.append(("Dispatcher Version", hypercorn.__version__))
192+
else:
193+
try:
194+
dispatcher.append(("Dispatcher Version", get_version("hypercorn")))
195+
except Exception:
196+
pass
197+
173198
if not dispatcher and "daphne" in sys.modules:
174199
dispatcher.append(("Dispatcher", "daphne"))
175200
daphne = sys.modules["daphne"]
@@ -191,15 +216,6 @@ def environment_settings():
191216

192217
plugins = []
193218

194-
get_version = None
195-
# importlib was introduced into the standard library starting in Python3.8.
196-
if "importlib" in sys.modules and hasattr(sys.modules["importlib"], "metadata"):
197-
get_version = sys.modules["importlib"].metadata.version
198-
elif "pkg_resources" in sys.modules:
199-
200-
def get_version(name): # pylint: disable=function-redefined
201-
return sys.modules["pkg_resources"].get_distribution(name).version
202-
203219
# Using any iterable to create a snapshot of sys.modules can occassionally
204220
# fail in a rare case when modules are imported in parallel by different
205221
# threads.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Copyright 2010 New Relic, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from newrelic.api.asgi_application import ASGIApplicationWrapper
16+
from newrelic.api.wsgi_application import WSGIApplicationWrapper
17+
from newrelic.common.object_wrapper import wrap_function_wrapper
18+
19+
20+
def bind_worker_serve(app, *args, **kwargs):
21+
return app, args, kwargs
22+
23+
24+
async def wrap_worker_serve(wrapped, instance, args, kwargs):
25+
import hypercorn
26+
27+
wrapper_module = getattr(hypercorn, "app_wrappers", None)
28+
asgi_wrapper_class = getattr(wrapper_module, "ASGIWrapper", None)
29+
wsgi_wrapper_class = getattr(wrapper_module, "WSGIWrapper", None)
30+
31+
app, args, kwargs = bind_worker_serve(*args, **kwargs)
32+
33+
# Hypercorn 0.14.1 introduced wrappers for ASGI and WSGI apps that need to be above our instrumentation.
34+
if asgi_wrapper_class is not None and isinstance(app, asgi_wrapper_class):
35+
app.app = ASGIApplicationWrapper(app.app)
36+
elif wsgi_wrapper_class is not None and isinstance(app, wsgi_wrapper_class):
37+
app.app = WSGIApplicationWrapper(app.app)
38+
else:
39+
app = ASGIApplicationWrapper(app)
40+
41+
app._nr_wrapped = True
42+
return await wrapped(app, *args, **kwargs)
43+
44+
45+
def bind_is_asgi(app):
46+
return app
47+
48+
49+
def wrap_is_asgi(wrapped, instance, args, kwargs):
50+
# Wrapper is identical and reused for the functions is_asgi and _is_asgi_2.
51+
app = bind_is_asgi(*args, **kwargs)
52+
53+
# Unwrap apps wrapped by our instrumentation.
54+
# ASGI 2/3 detection for hypercorn is unable to process
55+
# our wrappers and will return incorrect results. This
56+
# should be sufficient to allow hypercorn to run detection
57+
# on an application that was not wrapped by this instrumentation.
58+
while getattr(app, "_nr_wrapped", False):
59+
app = app.__wrapped__
60+
61+
return wrapped(app)
62+
63+
64+
def instrument_hypercorn_asyncio_run(module):
65+
if hasattr(module, "worker_serve"):
66+
wrap_function_wrapper(module, "worker_serve", wrap_worker_serve)
67+
68+
69+
def instrument_hypercorn_trio_run(module):
70+
if hasattr(module, "worker_serve"):
71+
wrap_function_wrapper(module, "worker_serve", wrap_worker_serve)
72+
73+
74+
def instrument_hypercorn_utils(module):
75+
if hasattr(module, "_is_asgi_2"):
76+
wrap_function_wrapper(module, "_is_asgi_2", wrap_is_asgi)
77+
78+
if hasattr(module, "is_asgi"):
79+
wrap_function_wrapper(module, "is_asgi", wrap_is_asgi)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Copyright 2010 New Relic, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from testing_support.fixture.event_loop import ( # noqa: F401; pylint: disable=W0611
16+
event_loop as loop,
17+
)
18+
from testing_support.fixtures import ( # noqa: F401; pylint: disable=W0611
19+
code_coverage_fixture,
20+
collector_agent_registration_fixture,
21+
collector_available_fixture,
22+
)
23+
24+
_coverage_source = [
25+
"newrelic.hooks.adapter_hypercorn",
26+
]
27+
28+
code_coverage = code_coverage_fixture(source=_coverage_source)
29+
30+
_default_settings = {
31+
"transaction_tracer.explain_threshold": 0.0,
32+
"transaction_tracer.transaction_threshold": 0.0,
33+
"transaction_tracer.stack_trace_threshold": 0.0,
34+
"debug.log_data_collector_payloads": True,
35+
"debug.record_transaction_failure": True,
36+
}
37+
38+
collector_agent_registration = collector_agent_registration_fixture(
39+
app_name="Python Agent Test (adapter_hypercorn)", default_settings=_default_settings
40+
)
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Copyright 2010 New Relic, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import asyncio
16+
import threading
17+
import time
18+
from urllib.request import HTTPError, urlopen
19+
20+
import pkg_resources
21+
import pytest
22+
from testing_support.fixtures import (
23+
override_application_settings,
24+
raise_background_exceptions,
25+
validate_transaction_errors,
26+
validate_transaction_metrics,
27+
wait_for_background_threads,
28+
)
29+
from testing_support.sample_asgi_applications import (
30+
AppWithCall,
31+
AppWithCallRaw,
32+
simple_app_v2_raw,
33+
)
34+
from testing_support.util import get_open_port
35+
36+
from newrelic.api.transaction import ignore_transaction
37+
from newrelic.common.object_names import callable_name
38+
39+
HYPERCORN_VERSION = tuple(int(v) for v in pkg_resources.get_distribution("hypercorn").version.split("."))
40+
asgi_2_unsupported = HYPERCORN_VERSION >= (0, 14, 1)
41+
wsgi_unsupported = HYPERCORN_VERSION < (0, 14, 1)
42+
43+
44+
def wsgi_app(environ, start_response):
45+
path = environ["PATH_INFO"]
46+
47+
if path == "/":
48+
start_response("200 OK", response_headers=[])
49+
elif path == "/ignored":
50+
ignore_transaction()
51+
start_response("200 OK", response_headers=[])
52+
elif path == "/exc":
53+
raise ValueError("whoopsies")
54+
55+
return []
56+
57+
58+
@pytest.fixture(
59+
params=(
60+
pytest.param(
61+
simple_app_v2_raw,
62+
marks=pytest.mark.skipif(asgi_2_unsupported, reason="ASGI2 unsupported"),
63+
),
64+
AppWithCallRaw(),
65+
AppWithCall(),
66+
pytest.param(
67+
wsgi_app,
68+
marks=pytest.mark.skipif(wsgi_unsupported, reason="WSGI unsupported"),
69+
),
70+
),
71+
ids=("raw", "class_with_call", "class_with_call_double_wrapped", "wsgi"),
72+
)
73+
def app(request):
74+
return request.param
75+
76+
77+
@pytest.fixture()
78+
def port(loop, app):
79+
import hypercorn.asyncio
80+
import hypercorn.config
81+
82+
port = get_open_port()
83+
shutdown = asyncio.Event()
84+
85+
def server_run():
86+
async def shutdown_trigger():
87+
await shutdown.wait()
88+
return True
89+
90+
config = hypercorn.config.Config.from_mapping(
91+
{
92+
"bind": ["127.0.0.1:%d" % port],
93+
}
94+
)
95+
96+
try:
97+
loop.run_until_complete(hypercorn.asyncio.serve(app, config, shutdown_trigger=shutdown_trigger))
98+
except Exception:
99+
pass
100+
101+
thread = threading.Thread(target=server_run, daemon=True)
102+
thread.start()
103+
wait_for_port(port)
104+
yield port
105+
106+
shutdown.set()
107+
loop.call_soon_threadsafe(loop.stop)
108+
thread.join(timeout=10)
109+
110+
if thread.is_alive():
111+
raise RuntimeError("Thread failed to exit in time.")
112+
113+
114+
def wait_for_port(port, retries=10):
115+
status = None
116+
for _ in range(retries):
117+
try:
118+
status = urlopen("http://localhost:%d/ignored" % port, timeout=1).status
119+
assert status == 200
120+
return
121+
except Exception as e:
122+
status = e
123+
124+
time.sleep(1)
125+
126+
raise RuntimeError("Failed to wait for port %d. Got status %s" % (port, status))
127+
128+
129+
@override_application_settings({"transaction_name.naming_scheme": "framework"})
130+
def test_hypercorn_200(port, app):
131+
@validate_transaction_metrics(callable_name(app))
132+
@raise_background_exceptions()
133+
@wait_for_background_threads()
134+
def response():
135+
return urlopen("http://localhost:%d" % port, timeout=10)
136+
137+
assert response().status == 200
138+
139+
140+
@override_application_settings({"transaction_name.naming_scheme": "framework"})
141+
def test_hypercorn_500(port, app):
142+
@validate_transaction_errors(["builtins:ValueError"])
143+
@validate_transaction_metrics(callable_name(app))
144+
@raise_background_exceptions()
145+
@wait_for_background_threads()
146+
def _test():
147+
with pytest.raises(HTTPError):
148+
urlopen("http://localhost:%d/exc" % port)
149+
150+
_test()

0 commit comments

Comments
 (0)