Skip to content

Commit

Permalink
Hypercorn ASGI Server Instrumentation (#598)
Browse files Browse the repository at this point in the history
* 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 <lrafeei@users.noreply.github.com>
Co-authored-by: Hannah Stepanek <hmstepanek@users.noreply.github.com>
Co-authored-by: Uma Annamalai <umaannamalai@users.noreply.github.com>

* 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 <TimPansino@users.noreply.github.com>
Co-authored-by: Lalleh Rafeei <lrafeei@users.noreply.github.com>
Co-authored-by: Hannah Stepanek <hmstepanek@users.noreply.github.com>
Co-authored-by: Uma Annamalai <umaannamalai@users.noreply.github.com>
  • Loading branch information
5 people authored Sep 1, 2022
1 parent 9040fbd commit d0896ae
Show file tree
Hide file tree
Showing 7 changed files with 345 additions and 21 deletions.
8 changes: 8 additions & 0 deletions newrelic/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2540,6 +2540,14 @@ def _process_module_builtin_defaults():

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

_process_module_definition(
"hypercorn.asyncio.run", "newrelic.hooks.adapter_hypercorn", "instrument_hypercorn_asyncio_run"
)
_process_module_definition(
"hypercorn.trio.run", "newrelic.hooks.adapter_hypercorn", "instrument_hypercorn_trio_run"
)
_process_module_definition("hypercorn.utils", "newrelic.hooks.adapter_hypercorn", "instrument_hypercorn_utils")

_process_module_definition("daphne.server", "newrelic.hooks.adapter_daphne", "instrument_daphne_server")

_process_module_definition("sanic.app", "newrelic.hooks.framework_sanic", "instrument_sanic_app")
Expand Down
34 changes: 25 additions & 9 deletions newrelic/core/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@
def environment_settings():
"""Returns an array of arrays of environment settings"""

# Find version resolver.

get_version = None
# importlib was introduced into the standard library starting in Python3.8.
if "importlib" in sys.modules and hasattr(sys.modules["importlib"], "metadata"):
get_version = sys.modules["importlib"].metadata.version
elif "pkg_resources" in sys.modules:

def get_version(name): # pylint: disable=function-redefined
return sys.modules["pkg_resources"].get_distribution(name).version

env = []

# Agent information.
Expand Down Expand Up @@ -104,6 +115,8 @@ def environment_settings():

dispatcher = []

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

if not dispatcher and "hypercorn" in sys.modules:
dispatcher.append(("Dispatcher", "hypercorn"))
hypercorn = sys.modules["hypercorn"]

if hasattr(hypercorn, "__version__"):
dispatcher.append(("Dispatcher Version", hypercorn.__version__))
else:
try:
dispatcher.append(("Dispatcher Version", get_version("hypercorn")))
except Exception:
pass

if not dispatcher and "daphne" in sys.modules:
dispatcher.append(("Dispatcher", "daphne"))
daphne = sys.modules["daphne"]
Expand All @@ -191,15 +216,6 @@ def environment_settings():

plugins = []

get_version = None
# importlib was introduced into the standard library starting in Python3.8.
if "importlib" in sys.modules and hasattr(sys.modules["importlib"], "metadata"):
get_version = sys.modules["importlib"].metadata.version
elif "pkg_resources" in sys.modules:

def get_version(name): # pylint: disable=function-redefined
return sys.modules["pkg_resources"].get_distribution(name).version

# Using any iterable to create a snapshot of sys.modules can occassionally
# fail in a rare case when modules are imported in parallel by different
# threads.
Expand Down
79 changes: 79 additions & 0 deletions newrelic/hooks/adapter_hypercorn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 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
from newrelic.api.wsgi_application import WSGIApplicationWrapper
from newrelic.common.object_wrapper import wrap_function_wrapper


def bind_worker_serve(app, *args, **kwargs):
return app, args, kwargs


async def wrap_worker_serve(wrapped, instance, args, kwargs):
import hypercorn

wrapper_module = getattr(hypercorn, "app_wrappers", None)
asgi_wrapper_class = getattr(wrapper_module, "ASGIWrapper", None)
wsgi_wrapper_class = getattr(wrapper_module, "WSGIWrapper", None)

app, args, kwargs = bind_worker_serve(*args, **kwargs)

# Hypercorn 0.14.1 introduced wrappers for ASGI and WSGI apps that need to be above our instrumentation.
if asgi_wrapper_class is not None and isinstance(app, asgi_wrapper_class):
app.app = ASGIApplicationWrapper(app.app)
elif wsgi_wrapper_class is not None and isinstance(app, wsgi_wrapper_class):
app.app = WSGIApplicationWrapper(app.app)
else:
app = ASGIApplicationWrapper(app)

app._nr_wrapped = True
return await wrapped(app, *args, **kwargs)


def bind_is_asgi(app):
return app


def wrap_is_asgi(wrapped, instance, args, kwargs):
# Wrapper is identical and reused for the functions is_asgi and _is_asgi_2.
app = bind_is_asgi(*args, **kwargs)

# Unwrap apps wrapped by our instrumentation.
# ASGI 2/3 detection for hypercorn is unable to process
# our wrappers and will return incorrect results. This
# should be sufficient to allow hypercorn to run detection
# on an application that was not wrapped by this instrumentation.
while getattr(app, "_nr_wrapped", False):
app = app.__wrapped__

return wrapped(app)


def instrument_hypercorn_asyncio_run(module):
if hasattr(module, "worker_serve"):
wrap_function_wrapper(module, "worker_serve", wrap_worker_serve)


def instrument_hypercorn_trio_run(module):
if hasattr(module, "worker_serve"):
wrap_function_wrapper(module, "worker_serve", wrap_worker_serve)


def instrument_hypercorn_utils(module):
if hasattr(module, "_is_asgi_2"):
wrap_function_wrapper(module, "_is_asgi_2", wrap_is_asgi)

if hasattr(module, "is_asgi"):
wrap_function_wrapper(module, "is_asgi", wrap_is_asgi)
40 changes: 40 additions & 0 deletions tests/adapter_hypercorn/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 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.fixture.event_loop import ( # noqa: F401; pylint: disable=W0611
event_loop as loop,
)
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_hypercorn",
]

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_hypercorn)", default_settings=_default_settings
)
150 changes: 150 additions & 0 deletions tests/adapter_hypercorn/test_hypercorn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# 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
import time
from urllib.request import HTTPError, urlopen

import pkg_resources
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.api.transaction import ignore_transaction
from newrelic.common.object_names import callable_name

HYPERCORN_VERSION = tuple(int(v) for v in pkg_resources.get_distribution("hypercorn").version.split("."))
asgi_2_unsupported = HYPERCORN_VERSION >= (0, 14, 1)
wsgi_unsupported = HYPERCORN_VERSION < (0, 14, 1)


def wsgi_app(environ, start_response):
path = environ["PATH_INFO"]

if path == "/":
start_response("200 OK", response_headers=[])
elif path == "/ignored":
ignore_transaction()
start_response("200 OK", response_headers=[])
elif path == "/exc":
raise ValueError("whoopsies")

return []


@pytest.fixture(
params=(
pytest.param(
simple_app_v2_raw,
marks=pytest.mark.skipif(asgi_2_unsupported, reason="ASGI2 unsupported"),
),
AppWithCallRaw(),
AppWithCall(),
pytest.param(
wsgi_app,
marks=pytest.mark.skipif(wsgi_unsupported, reason="WSGI unsupported"),
),
),
ids=("raw", "class_with_call", "class_with_call_double_wrapped", "wsgi"),
)
def app(request):
return request.param


@pytest.fixture()
def port(loop, app):
import hypercorn.asyncio
import hypercorn.config

port = get_open_port()
shutdown = asyncio.Event()

def server_run():
async def shutdown_trigger():
await shutdown.wait()
return True

config = hypercorn.config.Config.from_mapping(
{
"bind": ["127.0.0.1:%d" % port],
}
)

try:
loop.run_until_complete(hypercorn.asyncio.serve(app, config, shutdown_trigger=shutdown_trigger))
except Exception:
pass

thread = threading.Thread(target=server_run, daemon=True)
thread.start()
wait_for_port(port)
yield port

shutdown.set()
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=10)

if thread.is_alive():
raise RuntimeError("Thread failed to exit in time.")


def wait_for_port(port, retries=10):
status = None
for _ in range(retries):
try:
status = urlopen("http://localhost:%d/ignored" % port, timeout=1).status
assert status == 200
return
except Exception as e:
status = e

time.sleep(1)

raise RuntimeError("Failed to wait for port %d. Got status %s" % (port, status))


@override_application_settings({"transaction_name.naming_scheme": "framework"})
def test_hypercorn_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"})
def test_hypercorn_500(port, app):
@validate_transaction_errors(["builtins:ValueError"])
@validate_transaction_metrics(callable_name(app))
@raise_background_exceptions()
@wait_for_background_threads()
def _test():
with pytest.raises(HTTPError):
urlopen("http://localhost:%d/exc" % port)

_test()
Loading

0 comments on commit d0896ae

Please sign in to comment.