diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3d504df905..7b8ad5a113 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,6 +8,15 @@ In the event that a required notice is missing or incorrect, please notify us by e-mailing [support@newrelic.com](mailto:support@newrelic.com). +## [asgiref](https://pypi.org/project/asgiref/) + +Copyright (c) Django Software Foundation and individual contributors. + +Distributed under the following license(s): + + * [The BSD 3-Clause License](https://opensource.org/licenses/BSD-3-Clause) + + ## [six](https://pypi.org/project/six) Copyright (c) 2010-2013 Benjamin Peterson diff --git a/newrelic/agent.py b/newrelic/agent.py index 543839f729..05c88e6693 100644 --- a/newrelic/agent.py +++ b/newrelic/agent.py @@ -70,6 +70,18 @@ WSGIApplicationWrapper as __WSGIApplicationWrapper, wrap_wsgi_application as __wrap_wsgi_application) +try: + from newrelic.api.asgi_application import ( + asgi_application as __asgi_application, + ASGIApplicationWrapper as __ASGIApplicationWrapper, + wrap_asgi_application as __wrap_asgi_application) +except SyntaxError: + def __asgi_application(*args, **kwargs): + pass + + __ASGIApplicationWrapper = __asgi_application + __wrap_asgi_application = __asgi_application + from newrelic.api.web_transaction import ( WebTransaction as __WebTransaction, web_transaction as __web_transaction, @@ -258,6 +270,7 @@ current_trace_id = __wrap_api_call(__current_trace_id, 'current_trace_id') current_span_id = __wrap_api_call(__current_span_id, 'current_span_id') wsgi_application = __wsgi_application +asgi_application = __asgi_application WebTransaction = __wrap_api_call(__WebTransaction, 'WebTransaction') web_transaction = __wrap_api_call(__web_transaction, @@ -268,6 +281,8 @@ 'wrap_web_transaction') WSGIApplicationWrapper = __WSGIApplicationWrapper wrap_wsgi_application = __wrap_wsgi_application +ASGIApplicationWrapper = __ASGIApplicationWrapper +wrap_asgi_application = __wrap_asgi_application background_task = __wrap_api_call(__background_task, 'background_task') BackgroundTask = __wrap_api_call(__BackgroundTask, diff --git a/newrelic/api/asgi_application.py b/newrelic/api/asgi_application.py new file mode 100644 index 0000000000..67cef53dc0 --- /dev/null +++ b/newrelic/api/asgi_application.py @@ -0,0 +1,352 @@ +# 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 functools + +import newrelic.packages.asgiref_compatibility as asgiref_compatibility +import newrelic.packages.six as six +from newrelic.api.application import application_instance +from newrelic.api.web_transaction import WebTransaction +from newrelic.common.object_names import callable_name +from newrelic.common.object_wrapper import wrap_object, FunctionWrapper, function_wrapper +from newrelic.common.async_proxy import CoroutineProxy, LoopContext +from newrelic.api.html_insertion import insert_html_snippet, verify_body_exists + + +def _bind_scope(scope, *args, **kwargs): + return scope + + +def _bind_receive_send(scope, receive, send): + return receive, send + + +async def coro_function_wrapper(coro_function, receive, send): + return await coro_function(receive, send) + + +@function_wrapper +def double_to_single_callable(wrapped, instance, args, kwargs): + scope = _bind_scope(*args, **kwargs) + receive, send = _bind_receive_send(*args, **kwargs) + coro_function = wrapped(scope) + return coro_function_wrapper(coro_function, receive, send) + + +class ASGIBrowserMiddleware(object): + def __init__(self, app, transaction=None, search_maximum=64 * 1024): + self.app = app + self.send = None + self.messages = [] + self.initial_message = None + self.body = b"" + self.more_body = True + self.transaction = transaction + self.search_maximum = search_maximum + self.pass_through = not (transaction and transaction.enabled) + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + return await self.app(scope, receive, send) + + self.send = send + return await self.app(scope, receive, self.send_inject_browser_agent) + + async def send_buffered(self): + self.pass_through = True + await self.send(self.initial_message) + await self.send( + { + "type": "http.response.body", + "body": self.body, + "more_body": self.more_body, + } + ) + # Clear any saved messages + self.messages = None + + async def abort(self): + self.pass_through = True + for message in self.messages: + await self.send(message) + # Clear any saved messages + self.messages = None + + def should_insert_html(self, headers): + if self.transaction.autorum_disabled or self.transaction.rum_header_generated: + return False + + content_encoding = None + content_disposition = None + content_type = None + + for header_name, header_value in headers: + # assume header names are lower cased in accordance with ASGI spec + if header_name == b"content-type": + content_type = header_value + elif header_name == b"content-encoding": + content_encoding = header_value + elif header_name == b"content-disposition": + content_disposition = header_value + + if content_encoding is not None: + # This will match any encoding, including if the + # value 'identity' is used. Technically the value + # 'identity' should only be used in the header + # Accept-Encoding and not Content-Encoding. In + # other words, a WSGI application should not be + # returning identity. We could check and allow it + # anyway and still do RUM insertion, but don't. + + return False + + if content_type is None: + return False + + if ( + content_disposition is not None + and content_disposition.split(b";", 1)[0].strip().lower() == b"attachment" + ): + return False + + allowed_content_type = self.transaction.settings.browser_monitoring.content_type + + content_type = content_type.split(b";", 1)[0].decode("utf-8") + + if content_type not in allowed_content_type: + return False + + return True + + async def send_inject_browser_agent(self, message): + if self.pass_through: + return await self.send(message) + + # Store messages in case of an abort + self.messages.append(message) + + message_type = message["type"] + if message_type == "http.response.start" and not self.initial_message: + headers = list(message.get("headers", ())) + 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: + body = message.get("body", b"") + self.more_body = message.get("more_body", False) + + # Add this message to the current body + self.body += body + + # if there's a valid body string, attempt to insert the HTML + if verify_body_exists(self.body): + header = self.transaction.browser_timing_header() + if not header: + # If there's no header, abort browser monitoring injection + await self.send_buffered() + return + + footer = self.transaction.browser_timing_footer() + browser_agent_data = six.b(header) + six.b(footer) + + body = insert_html_snippet( + self.body, lambda: browser_agent_data, self.search_maximum + ) + + # If we have inserted the browser agent + if len(body) != len(self.body): + # check to see if we have to modify the content-length + # header + headers = self.initial_message["headers"] + for header_index, header_data in enumerate(headers): + header_name, header_value = header_data + if header_name.lower() == b"content-length": + break + else: + header_value = None + + try: + content_length = int(header_value) + except ValueError: + # Invalid content length results in an abort + await self.send_buffered() + return + + if content_length is not None: + delta = len(body) - len(self.body) + headers[header_index] = ( + b"content-length", + str(content_length + delta).encode("utf-8"), + ) + + # Body is found and modified so we can now send the + # modified data and stop searching + self.body = body + await self.send_buffered() + return + + # 1. Body is found but not modified + # 2. Body is not found + + # No more body + if not self.more_body: + await self.send_buffered() + + # We have hit our search limit + elif len(self.body) >= self.search_maximum: + await self.send_buffered() + + # Protocol error, unexpected message: abort + else: + await self.abort() + + +class ASGIWebTransaction(WebTransaction): + def __init__(self, application, scope, receive, send): + self.receive = receive + self._send = send + scheme = scope.get("scheme", "http") + if "server" in scope: + host, port = scope["server"] = tuple(scope["server"]) + else: + host, port = None, None + request_method = scope["method"] + request_path = scope["path"] + query_string = scope["query_string"] + headers = scope["headers"] = tuple(scope["headers"]) + super(ASGIWebTransaction, self).__init__( + application=application, + name=None, + scheme=scheme, + host=host, + port=port, + request_method=request_method, + request_path=request_path, + query_string=query_string, + headers=headers, + ) + + if self._settings: + self.capture_params = self._settings.capture_params + + async def send(self, event): + if event["type"] == "http.response.start": + self.process_response(event["status"], event.get("headers", ())) + return await self._send(event) + + +def ASGIApplicationWrapper( + wrapped, application=None, name=None, group=None, framework=None +): + + def nr_asgi_wrapper(wrapped, instance, args, kwargs): + double_callable = asgiref_compatibility.is_double_callable(wrapped) + if double_callable: + is_v2_signature = (len(args) + len(kwargs)) == 1 + if not is_v2_signature: + return wrapped(*args, **kwargs) + wrapped = double_to_single_callable(wrapped) + + scope = _bind_scope(*args, **kwargs) + + if scope["type"] != "http": + return wrapped(*args, **kwargs) + + async def nr_async_asgi(receive, send): + with ASGIWebTransaction( + application=application_instance(application), + scope=scope, + receive=receive, + send=send, + ) as transaction: + + # Record details of framework against the transaction for later + # reporting as supportability metrics. + if framework: + transaction.add_framework_info( + name=framework[0], version=framework[1] + ) + + # Override the initial web transaction name to be the supplied + # name, or the name of the wrapped callable if wanting to use + # the callable as the default. This will override the use of a + # raw URL which can result in metric grouping issues where a + # framework is not instrumented or is leaking URLs. + # + # Note that at present if default for naming scheme is still + # None and we aren't specifically wrapping a designated + # framework, then we still allow old URL based naming to + # override. When we switch to always forcing a name we need to + # check for naming scheme being None here. + + settings = transaction._settings + + if name is None and settings: + naming_scheme = settings.transaction_name.naming_scheme + + if framework is not None: + if naming_scheme in (None, "framework"): + transaction.set_transaction_name( + callable_name(wrapped), priority=1 + ) + + elif naming_scheme in ("component", "framework"): + transaction.set_transaction_name( + callable_name(wrapped), priority=1 + ) + + elif name: + transaction.set_transaction_name(name, group, priority=1) + + if ( + settings + and settings.browser_monitoring.enabled + and not transaction.autorum_disabled + ): + app = ASGIBrowserMiddleware(wrapped, transaction) + else: + app = wrapped + coro = app(scope, transaction.receive, transaction.send) + coro = CoroutineProxy(coro, LoopContext()) + return await coro + + if double_callable: + return nr_async_asgi + else: + return nr_async_asgi(*_bind_receive_send(*args, **kwargs)) + + return FunctionWrapper(wrapped, nr_asgi_wrapper) + + +def asgi_application(application=None, name=None, group=None, framework=None): + return functools.partial( + ASGIApplicationWrapper, + application=application, + name=name, + group=group, + framework=framework, + ) + + +def wrap_asgi_application( + module, object_path, application=None, name=None, group=None, framework=None +): + wrap_object( + module, + object_path, + ASGIApplicationWrapper, + (application, name, group, framework), + ) diff --git a/newrelic/packages/asgiref_compatibility.py b/newrelic/packages/asgiref_compatibility.py new file mode 100644 index 0000000000..f5b029b1da --- /dev/null +++ b/newrelic/packages/asgiref_compatibility.py @@ -0,0 +1,75 @@ +# Copyright (c) Django Software Foundation and individual contributors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# 3. Neither the name of Django nor the names of its contributors may be used +# to endorse or promote products derived from this software without +# specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import asyncio +import inspect + + +def is_double_callable(application): + """ + Tests to see if an application is a legacy-style (double-callable) application. + """ + # Look for a hint on the object first + if getattr(application, "_asgi_single_callable", False): + return False + if getattr(application, "_asgi_double_callable", False): + return True + # Uninstanted classes are double-callable + if inspect.isclass(application): + return True + # Instanted classes depend on their __call__ + if hasattr(application, "__call__"): + # We only check to see if its __call__ is a coroutine function - + # if it's not, it still might be a coroutine function itself. + if asyncio.iscoroutinefunction(application.__call__): + return False + # Non-classes we just check directly + return not asyncio.iscoroutinefunction(application) + + +def double_to_single_callable(application): + """ + Transforms a double-callable ASGI application into a single-callable one. + """ + + async def new_application(scope, receive, send): + instance = application(scope) + return await instance(receive, send) + + return new_application + + +def guarantee_single_callable(application): + """ + Takes either a single- or double-callable application and always returns it + in single-callable style. Use this to add backwards compatibility for ASGI + 2.0 applications to your server/test harness/etc. + """ + if is_double_callable(application): + application = double_to_single_callable(application) + return application diff --git a/tests/agent_features/conftest.py b/tests/agent_features/conftest.py index ae88bd3bfe..1d4e2babf4 100644 --- a/tests/agent_features/conftest.py +++ b/tests/agent_features/conftest.py @@ -59,4 +59,8 @@ def requires_data_collector(collector_available_fixture): 'test_coroutine_transaction.py', 'test_async_timing.py', 'test_event_loop_wait_time.py', + 'test_asgi_transaction.py', + 'test_asgi_browser.py', + 'test_asgi_distributed_tracing.py', + 'test_asgi_w3c_trace_context.py' ] diff --git a/tests/agent_features/test_asgi_browser.py b/tests/agent_features/test_asgi_browser.py new file mode 100644 index 0000000000..a7f4fd4f57 --- /dev/null +++ b/tests/agent_features/test_asgi_browser.py @@ -0,0 +1,873 @@ +# 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 sys +import json + +import pytest +import six + +from testing_support.fixtures import (override_application_settings, + validate_transaction_errors, validate_custom_parameters) +from testing_support.asgi_testing import AsgiTest + +from newrelic.api.application import application_settings +from newrelic.api.transaction import (get_browser_timing_header, + get_browser_timing_footer, add_custom_parameter, + disable_browser_autorum) +from newrelic.api.asgi_application import asgi_application +from newrelic.common.encoding_utils import deobfuscate + +from bs4 import BeautifulSoup + +_runtime_error_name = (RuntimeError.__module__ + ':' + RuntimeError.__name__) + +@asgi_application() +async def target_asgi_application_manual_rum(scope, receive, send): + text = '%s

RESPONSE

%s' + + output = (text % (get_browser_timing_header(), + get_browser_timing_footer())).encode('UTF-8') + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode('utf-8'))] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_manual_rum = AsgiTest(target_asgi_application_manual_rum) + +_test_footer_attributes = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': False, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_footer_attributes) +def test_footer_attributes(): + settings = application_settings() + + assert settings.browser_monitoring.enabled + + assert settings.browser_key + assert settings.browser_monitoring.loader_version + assert settings.js_agent_loader + assert isinstance(settings.js_agent_file, six.string_types) + assert settings.beacon + assert settings.error_beacon + + token = '0123456789ABCDEF' + headers = { 'Cookie': 'NRAGENT=tk=%s' % token } + + response = target_application_manual_rum.get('/', headers=headers) + + html = BeautifulSoup(response.body, 'html.parser') + header = html.html.head.script.string + content = html.html.body.p.string + footer = html.html.body.script.string + + # Validate actual body content. + + assert content == 'RESPONSE' + + # Validate the insertion of RUM header. + + assert header.find('NREUM HEADER') != -1 + + # Now validate the various fields of the footer. The fields are + # held by a JSON dictionary. + + data = json.loads(footer.split('NREUM.info=')[1]) + + assert data['licenseKey'] == settings.browser_key + assert data['applicationID'] == settings.application_id + + assert data['agent'] == settings.js_agent_file + assert data['beacon'] == settings.beacon + assert data['errorBeacon'] == settings.error_beacon + + assert data['applicationTime'] >= 0 + assert data['queueTime'] >= 0 + + obfuscation_key = settings.license_key[:13] + + assert type(data['transactionName']) == type(u'') + + txn_name = deobfuscate(data['transactionName'], obfuscation_key) + + assert txn_name == u'WebTransaction/Uri/' + + assert 'atts' not in data + +_test_rum_ssl_for_http_is_none = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': False, + 'browser_monitoring.ssl_for_http': None, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_rum_ssl_for_http_is_none) +def test_ssl_for_http_is_none(): + settings = application_settings() + + assert settings.browser_monitoring.ssl_for_http is None + + response = target_application_manual_rum.get('/') + html = BeautifulSoup(response.body, 'html.parser') + footer = html.html.body.script.string + data = json.loads(footer.split('NREUM.info=')[1]) + + assert 'sslForHttp' not in data + +_test_rum_ssl_for_http_is_true = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': False, + 'browser_monitoring.ssl_for_http': True, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_rum_ssl_for_http_is_true) +def test_ssl_for_http_is_true(): + settings = application_settings() + + assert settings.browser_monitoring.ssl_for_http is True + + response = target_application_manual_rum.get('/') + html = BeautifulSoup(response.body, 'html.parser') + footer = html.html.body.script.string + data = json.loads(footer.split('NREUM.info=')[1]) + + assert data['sslForHttp'] is True + +_test_rum_ssl_for_http_is_false = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': False, + 'browser_monitoring.ssl_for_http': False, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_rum_ssl_for_http_is_false) +def test_ssl_for_http_is_false(): + settings = application_settings() + + assert settings.browser_monitoring.ssl_for_http is False + + response = target_application_manual_rum.get('/') + html = BeautifulSoup(response.body, 'html.parser') + footer = html.html.body.script.string + data = json.loads(footer.split('NREUM.info=')[1]) + + assert data['sslForHttp'] is False + +@asgi_application() +async def target_asgi_application_yield_single_no_head(scope, receive, send): + output = b'

RESPONSE

' + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode('utf-8'))] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_yield_single_no_head = AsgiTest( + target_asgi_application_yield_single_no_head) + +_test_html_insertion_yield_single_no_head_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_html_insertion_yield_single_no_head_settings) +def test_html_insertion_yield_single_no_head(): + response = target_application_yield_single_no_head.get('/') + assert response.status == 200 + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + + # The 'NREUM HEADER' value comes from our override for the header. + # The 'NREUM.info' value comes from the programmatically generated + # footer added by the agent. + + assert b'NREUM HEADER' in response.body + assert b'NREUM.info' in response.body + +@asgi_application() +async def target_asgi_application_yield_multi_no_head(scope, receive, send): + output = [ b'', b'

RESPONSE

' ] + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(b''.join(output))).encode('utf-8'))] + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + + for data in output: + more_body = data is not output[-1] + await send({"type": "http.response.body", "body": data, "more_body": more_body}) + +target_application_yield_multi_no_head = AsgiTest( + target_asgi_application_yield_multi_no_head) + +_test_html_insertion_yield_multi_no_head_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_html_insertion_yield_multi_no_head_settings) +def test_html_insertion_yield_multi_no_head(): + response = target_application_yield_multi_no_head.get('/') + assert response.status == 200 + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + + # The 'NREUM HEADER' value comes from our override for the header. + # The 'NREUM.info' value comes from the programmatically generated + # footer added by the agent. + + assert b'NREUM HEADER' in response.body + assert b'NREUM.info' in response.body + +@asgi_application() +async def target_asgi_application_unnamed_attachment_header(scope, receive, send): + output = b'

RESPONSE

' + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode('utf-8')), + (b'content-disposition', b'attachment')] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_unnamed_attachment_header = AsgiTest( + target_asgi_application_unnamed_attachment_header) + +_test_html_insertion_unnamed_attachment_header_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings( + _test_html_insertion_unnamed_attachment_header_settings) +def test_html_insertion_unnamed_attachment_header(): + response = target_application_unnamed_attachment_header.get('/') + assert response.status == 200 + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + assert 'content-disposition' in response.headers + + # The 'NREUM HEADER' value comes from our override for the header. + # The 'NREUM.info' value comes from the programmatically generated + # footer added by the agent. + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body + +@asgi_application() +async def target_asgi_application_named_attachment_header(scope, receive, send): + output = b'

RESPONSE

' + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode('utf-8')), + (b'content-disposition', b'Attachment; filename="X"')] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_named_attachment_header = AsgiTest( + target_asgi_application_named_attachment_header) + +_test_html_insertion_named_attachment_header_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings( + _test_html_insertion_named_attachment_header_settings) +def test_html_insertion_named_attachment_header(): + response = target_application_named_attachment_header.get('/') + assert response.status == 200 + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + assert 'content-disposition' in response.headers + + # The 'NREUM HEADER' value comes from our override for the header. + # The 'NREUM.info' value comes from the programmatically generated + # footer added by the agent. + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body + +@asgi_application() +async def target_asgi_application_inline_attachment_header(scope, receive, send): + output = b'

RESPONSE

' + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode('utf-8')), + (b'content-disposition', b'inline; filename="attachment"')] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_inline_attachment_header = AsgiTest( + target_asgi_application_inline_attachment_header) + +_test_html_insertion_inline_attachment_header_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings( + _test_html_insertion_inline_attachment_header_settings) +def test_html_insertion_inline_attachment_header(): + response = target_application_inline_attachment_header.get('/') + assert response.status == 200 + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + assert 'content-disposition' in response.headers + + # The 'NREUM HEADER' value comes from our override for the header. + # The 'NREUM.info' value comes from the programmatically generated + # footer added by the agent. + + assert b'NREUM HEADER' in response.body + assert b'NREUM.info' in response.body + +@asgi_application() +async def target_asgi_application_empty(scope, receive, send): + status = '200 OK' + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', b'0')] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body"}) + +target_application_empty = AsgiTest( + target_asgi_application_empty) + +_test_html_insertion_empty_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings( + _test_html_insertion_empty_settings) +def test_html_insertion_empty(): + response = target_application_empty.get('/') + assert response.status == 200 + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + + # The 'NREUM HEADER' value comes from our override for the header. + # The 'NREUM.info' value comes from the programmatically generated + # footer added by the agent. + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body + + assert len(response.body) == 0 + +@asgi_application() +async def target_asgi_application_single_empty_string(scope, receive, send): + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', b'0')] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": b""}) + +target_application_single_empty_string = AsgiTest( + target_asgi_application_single_empty_string) + +_test_html_insertion_single_empty_string_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings( + _test_html_insertion_single_empty_string_settings) +def test_html_insertion_single_empty_string(): + response = target_application_single_empty_string.get('/') + assert response.status == 200 + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + + # The 'NREUM HEADER' value comes from our override for the header. + # The 'NREUM.info' value comes from the programmatically generated + # footer added by the agent. + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body + + assert len(response.body) == 0 + +@asgi_application() +async def target_asgi_application_multiple_empty_string(scope, receive, send): + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', b'0')] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": b"", "more_body": True}) + await send({"type": "http.response.body", "body": b""}) + +target_application_multiple_empty_string = AsgiTest( + target_asgi_application_multiple_empty_string) + +_test_html_insertion_multiple_empty_string_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings( + _test_html_insertion_multiple_empty_string_settings) +def test_html_insertion_multiple_empty_string(): + response = target_application_multiple_empty_string.get('/') + assert response.status == 200 + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + + # The 'NREUM HEADER' value comes from our override for the header. + # The 'NREUM.info' value comes from the programmatically generated + # footer added by the agent. + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body + + assert len(response.body) == 0 + +@asgi_application() +async def target_asgi_application_single_large_prelude(scope, receive, send): + output = 64*1024*b' ' + b'' + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode("utf-8"))] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_single_large_prelude = AsgiTest( + target_asgi_application_single_large_prelude) + +_test_html_insertion_single_large_prelude_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings( + _test_html_insertion_single_large_prelude_settings) +def test_html_insertion_single_large_prelude(): + response = target_application_single_large_prelude.get('/') + assert response.status == 200 + + # The 'NREUM HEADER' value comes from our override for the header. + # The 'NREUM.info' value comes from the programmatically generated + # footer added by the agent. + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body + + output = [32*1024*b' ', 32*1024*b' ', b''] + + assert len(response.body) == len(b''.join(output)) + +@asgi_application() +async def target_asgi_application_multi_large_prelude(scope, receive, send): + output = [32*1024*b' ', 32*1024*b' ', b''] + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(b''.join(output))).encode("utf-8"))] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + for data in output: + more_body = data is not output[-1] + await send({"type": "http.response.body", "body": data, "more_body": more_body}) + +target_application_multi_large_prelude = AsgiTest( + target_asgi_application_multi_large_prelude) + +_test_html_insertion_multi_large_prelude_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings( + _test_html_insertion_multi_large_prelude_settings) +def test_html_insertion_multi_large_prelude(): + response = target_application_multi_large_prelude.get('/') + assert response.status == 200 + + # The 'NREUM HEADER' value comes from our override for the header. + # The 'NREUM.info' value comes from the programmatically generated + # footer added by the agent. + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body + + output = [32*1024*b' ', 32*1024*b' ', b''] + + assert len(response.body) == len(b''.join(output)) + +@asgi_application() +async def target_asgi_application_yield_before_start(scope, receive, send): + # This is not legal but we should see what happens with our middleware + await send({"type": "http.response.body", "body": b"", "more_body": True}) + + output = b'

RESPONSE

' + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode("utf-8"))] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_yield_before_start = AsgiTest( + target_asgi_application_yield_before_start) + +_test_html_insertion_yield_before_start_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_html_insertion_yield_before_start_settings) +def test_html_insertion_yield_before_start(): + # The application should complete as pass through, but an assertion error + # would be raised in the AsgiTest class + with pytest.raises(AssertionError): + target_application_yield_before_start.get('/') + +@asgi_application() +async def target_asgi_application_start_yield_start(scope, receive, send): + output = b'

RESPONSE

' + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode("utf-8"))] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": b""}) + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + +target_application_start_yield_start = AsgiTest( + target_asgi_application_start_yield_start) + +_test_html_insertion_start_yield_start_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_html_insertion_start_yield_start_settings) +def test_html_insertion_start_yield_start(): + # The application should complete as pass through, but an assertion error + # would be raised in the AsgiTest class + with pytest.raises(AssertionError): + target_application_start_yield_start.get('/') + +@asgi_application() +async def target_asgi_application_invalid_content_length(scope, receive, send): + output = b'

RESPONSE

' + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', b'XXX')] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_invalid_content_length = AsgiTest( + target_asgi_application_invalid_content_length) + +_test_html_insertion_invalid_content_length_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_html_insertion_invalid_content_length_settings) +def test_html_insertion_invalid_content_length(): + response = target_application_invalid_content_length.get('/') + assert response.status == 200 + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + + assert response.headers['content-length'] == 'XXX' + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body + +@asgi_application() +async def target_asgi_application_content_encoding(scope, receive, send): + output = b'

RESPONSE

' + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode("utf-8")), + (b'content-encoding', b'identity')] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_content_encoding = AsgiTest( + target_asgi_application_content_encoding) + +_test_html_insertion_content_encoding_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_html_insertion_content_encoding_settings) +def test_html_insertion_content_encoding(): + response = target_application_content_encoding.get('/') + assert response.status == 200 + + # Technically 'identity' should not be used in Content-Encoding + # but clients will still accept it. Use this fact to disable auto + # RUM for this test. Other option is to compress the response + # and use 'gzip'. + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + + assert response.headers['content-encoding'] == 'identity' + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body + +@asgi_application() +async def target_asgi_application_no_content_type(scope, receive, send): + output = b'

RESPONSE

' + + response_headers = [(b'content-length', str(len(output)).encode("utf-8"))] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_no_content_type = AsgiTest( + target_asgi_application_no_content_type) + +_test_html_insertion_no_content_type_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_html_insertion_no_content_type_settings) +def test_html_insertion_no_content_type(): + response = target_application_no_content_type.get('/') + assert response.status == 200 + + assert 'content-type' not in response.headers + assert 'content-length' in response.headers + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body + +@asgi_application() +async def target_asgi_application_plain_text(scope, receive, send): + output = b'RESPONSE' + + response_headers = [ + (b'content-type', b'text/plain'), + (b'content-length', str(len(output)).encode("utf-8"))] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_plain_text = AsgiTest( + target_asgi_application_plain_text) + +_test_html_insertion_plain_text_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_html_insertion_plain_text_settings) +def test_html_insertion_plain_text(): + response = target_application_plain_text.get('/') + assert response.status == 200 + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body + +@asgi_application() +async def target_asgi_application_param_on_close(scope, receive, send): + output = b'

RESPONSE

' + + response_headers = [ + (b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode("utf-8"))] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + try: + await send({"type": "http.response.body", "body": output}) + return + finally: + add_custom_parameter('key', 'value') + +target_application_param_on_close = AsgiTest( + target_asgi_application_param_on_close) + +_test_html_insertion_param_on_close_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_html_insertion_param_on_close_settings) +@validate_custom_parameters(required_params=[('key', 'value')]) +def test_html_insertion_param_on_close(): + response = target_application_param_on_close.get('/') + assert response.status == 200 + + assert b'NREUM HEADER' in response.body + assert b'NREUM.info' in response.body + +@asgi_application() +async def target_asgi_application_param_on_error(scope, receive, send): + output = b'

RESPONSE

' + + response_headers = [ + (b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode("utf-8"))] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + + try: + raise RuntimeError('ERROR') + finally: + add_custom_parameter('key', 'value') + +target_application_param_on_error = AsgiTest( + target_asgi_application_param_on_error) + +_test_html_insertion_param_on_error_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings(_test_html_insertion_param_on_error_settings) +@validate_transaction_errors(errors=[_runtime_error_name]) +@validate_custom_parameters(required_params=[('key', 'value')]) +def test_html_insertion_param_on_error(): + try: + target_application_param_on_error.get('/') + except RuntimeError: + pass + +@asgi_application() +async def target_asgi_application_disable_autorum_via_api(scope, receive, send): + output = b'

RESPONSE

' + + disable_browser_autorum() + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode("utf-8"))] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_disable_autorum_via_api = AsgiTest( + target_asgi_application_disable_autorum_via_api) + +_test_html_insertion_disable_autorum_via_api_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings( + _test_html_insertion_disable_autorum_via_api_settings) +def test_html_insertion_disable_autorum_via_api(): + response = target_application_disable_autorum_via_api.get('/') + assert response.status == 200 + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + + # The 'NREUM HEADER' value comes from our override for the header. + # The 'NREUM.info' value comes from the programmatically generated + # footer added by the agent. + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body + +@asgi_application() +async def target_asgi_application_manual_rum_insertion(scope, receive, send): + output = b'

RESPONSE

' + + header = get_browser_timing_header() + footer = get_browser_timing_footer() + + header = get_browser_timing_header() + footer = get_browser_timing_footer() + + assert header == '' + assert footer == '' + + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode("utf-8"))] + + await send({"type": "http.response.start", "status": 200, "headers": response_headers}) + await send({"type": "http.response.body", "body": output}) + +target_application_manual_rum_insertion = AsgiTest( + target_asgi_application_manual_rum_insertion) + +_test_html_insertion_manual_rum_insertion_settings = { + 'browser_monitoring.enabled': True, + 'browser_monitoring.auto_instrument': True, + 'js_agent_loader': u'', +} + +@override_application_settings( + _test_html_insertion_manual_rum_insertion_settings) +def test_html_insertion_manual_rum_insertion(): + response = target_application_manual_rum_insertion.get('/') + assert response.status == 200 + + assert 'content-type' in response.headers + assert 'content-length' in response.headers + + # The 'NREUM HEADER' value comes from our override for the header. + # The 'NREUM.info' value comes from the programmatically generated + # footer added by the agent. + + assert b'NREUM HEADER' not in response.body + assert b'NREUM.info' not in response.body diff --git a/tests/agent_features/test_asgi_distributed_tracing.py b/tests/agent_features/test_asgi_distributed_tracing.py new file mode 100644 index 0000000000..b043111322 --- /dev/null +++ b/tests/agent_features/test_asgi_distributed_tracing.py @@ -0,0 +1,205 @@ +# 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 json +import pytest +import copy + +from newrelic.api.application import application_instance +from newrelic.api.background_task import BackgroundTask +from newrelic.api.transaction import current_transaction +from newrelic.api.asgi_application import asgi_application, ASGIWebTransaction + +from testing_support.asgi_testing import AsgiTest +from testing_support.fixtures import (override_application_settings, + validate_transaction_metrics) + + +distributed_trace_intrinsics = ['guid', 'traceId', 'priority', 'sampled'] +inbound_payload_intrinsics = ['parent.type', 'parent.app', 'parent.account', + 'parent.transportType', 'parent.transportDuration'] + +payload = { + 'v': [0, 1], + 'd': { + 'ac': '1', + 'ap': '2827902', + 'id': '7d3efb1b173fecfa', + 'pa': '5e5733a911cfbc73', + 'pr': 10.001, + 'sa': True, + 'ti': 1518469636035, + 'tr': 'd6b4ba0c3a712ca', + 'ty': 'App', + } +} +parent_order = ['parent_type', 'parent_account', + 'parent_app', 'parent_transport_type'] +parent_info = { + 'parent_type': payload['d']['ty'], + 'parent_account': payload['d']['ac'], + 'parent_app': payload['d']['ap'], + 'parent_transport_type': 'HTTP' +} + + +@asgi_application() +async def target_asgi_application(scope, receive, send): + status = '200 OK' + type = "http.response.start" + output = b'hello world' + response_headers = [(b'content-type', b'text/html; charset=utf-8'), + (b'content-length', str(len(output)).encode('utf-8'))] + + txn = current_transaction() + + # Make assertions on the ASGIWebTransaction object + assert txn._distributed_trace_state + assert txn.parent_type == 'App' + assert txn.parent_app == '2827902' + assert txn.parent_account == '1' + assert txn.parent_span == '7d3efb1b173fecfa' + assert txn.parent_transport_type == 'HTTP' + assert isinstance(txn.parent_transport_duration, float) + assert txn._trace_id == 'd6b4ba0c3a712ca' + assert txn.priority == 10.001 + assert txn.sampled + + await send({ + "type": type, + "status": status, + "headers": response_headers, + }) + + await send({ + "type": "http.response.body", + "body": b"Hello World", + }) + + return [output] + + +test_application = AsgiTest(target_asgi_application) + + +_override_settings = { + 'trusted_account_key': '1', + 'distributed_tracing.enabled': True, +} + +_metrics = [ + ('Supportability/DistributedTrace/AcceptPayload/Success', 1), + ('Supportability/TraceContext/Accept/Success', None) +] + + +@override_application_settings(_override_settings) +@validate_transaction_metrics( + '', + group='Uri', + rollup_metrics=_metrics) +def test_distributed_tracing_web_transaction(): + headers = {'newrelic': json.dumps(payload)} + response = test_application.make_request('GET', '/', headers=headers) + assert 'X-NewRelic-App-Data' not in response.headers + + +class TestAsgiRequest(object): + scope = { + 'asgi': {'spec_version': '2.1', 'version': '3.0'}, + 'client': ('127.0.0.1', 54768), + 'headers': [(b'host', b'localhost:8000')], + 'http_version': '1.1', + 'method': 'GET', + 'path': '/', + 'query_string': b'', + 'raw_path': b'/', + 'root_path': '', + 'scheme': 'http', + 'server': ('127.0.0.1', 8000), + 'type': 'http' + } + + async def receive(self): + pass + + async def send(self, event): + pass + + +# test our distributed_trace metrics by creating a transaction and then forcing +# it to process a distributed trace payload +@pytest.mark.parametrize('web_transaction', (True, False)) +@pytest.mark.parametrize('gen_error', (True, False)) +@pytest.mark.parametrize('has_parent', (True, False)) +def test_distributed_tracing_metrics(web_transaction, gen_error, has_parent): + def _make_dt_tag(pi): + return "%s/%s/%s/%s/all" % tuple(pi[x] for x in parent_order) + + # figure out which metrics we'll see based on the test params + # note: we'll always see DurationByCaller if the distributed + # tracing flag is turned on + metrics = ['DurationByCaller'] + if gen_error: + metrics.append('ErrorsByCaller') + if has_parent: + metrics.append('TransportDuration') + + tag = None + dt_payload = copy.deepcopy(payload) + + # if has_parent is True, our metric name will be info about the parent, + # otherwise it is Unknown/Unknown/Unknown/Unknown + if has_parent: + tag = _make_dt_tag(parent_info) + else: + tag = _make_dt_tag(dict((x, 'Unknown') for x in parent_info.keys())) + del dt_payload['d']['tr'] + + # now run the test + transaction_name = "test_dt_metrics_%s" % '_'.join(metrics) + _rollup_metrics = [ + ("%s/%s%s" % (x, tag, bt), 1) + for x in metrics + for bt in ['', 'Web' if web_transaction else 'Other'] + ] + + def _make_test_transaction(): + application = application_instance() + request = TestAsgiRequest() + + if not web_transaction: + return BackgroundTask(application, transaction_name) + + tn = ASGIWebTransaction(application, request.scope, + request.send, request.receive) + tn.set_transaction_name(transaction_name) + return tn + + @override_application_settings(_override_settings) + @validate_transaction_metrics( + transaction_name, + background_task=not(web_transaction), + rollup_metrics=_rollup_metrics) + def _test(): + with _make_test_transaction() as transaction: + transaction.accept_distributed_trace_payload(dt_payload) + + if gen_error: + try: + 1 / 0 + except ZeroDivisionError: + transaction.record_exception() + + _test() diff --git a/tests/agent_features/test_asgi_transaction.py b/tests/agent_features/test_asgi_transaction.py new file mode 100644 index 0000000000..4bc57dad90 --- /dev/null +++ b/tests/agent_features/test_asgi_transaction.py @@ -0,0 +1,126 @@ +# 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 pytest +from testing_support.sample_asgi_applications import simple_app_v2_raw, simple_app_v3_raw, simple_app_v2, simple_app_v3, AppWithDescriptor +from testing_support.fixtures import validate_transaction_metrics, override_application_settings, function_not_called +from newrelic.api.asgi_application import asgi_application, ASGIApplicationWrapper +from testing_support.asgi_testing import AsgiTest + +#Setup test apps from sample_asgi_applications.py +simple_app_v2_original = AsgiTest(simple_app_v2_raw) +simple_app_v3_original = AsgiTest(simple_app_v3_raw) + +simple_app_v3_wrapped = AsgiTest(simple_app_v3) +simple_app_v2_wrapped = AsgiTest(simple_app_v2) + +#Test naming scheme logic and ASGIApplicationWrapper for a single callable +@pytest.mark.parametrize("naming_scheme", (None, "component", "framework")) +def test_single_callable_naming_scheme(naming_scheme): + + if naming_scheme in ("component", "framework"): + expected_name = "testing_support.sample_asgi_applications:simple_app_v3_raw" + expected_group = "Function" + else: + expected_name = "" + expected_group = "Uri" + + @validate_transaction_metrics(name=expected_name, group=expected_group) + @override_application_settings({"transaction_name.naming_scheme": naming_scheme}) + def _test(): + response = simple_app_v3_wrapped.make_request("GET", "/") + assert response.status == 200 + assert response.headers == {} + assert response.body == b"" + + _test() + + +#Test the default naming scheme logic and ASGIApplicationWrapper for a double callable +@validate_transaction_metrics(name="", group="Uri") +def test_double_callable_default_naming_scheme(): + response = simple_app_v2_wrapped.make_request("GET", "/") + assert response.status == 200 + assert response.headers == {} + assert response.body == b"" + + +#No harm test on single callable asgi app with agent disabled to ensure proper response +def test_single_callable_raw(): + response = simple_app_v3_original.make_request("GET", "/") + assert response.status == 200 + assert response.headers == {} + assert response.body == b"" + + +#No harm test on double callable asgi app with agent disabled to ensure proper response +def test_double_callable_raw(): + response = simple_app_v2_original.make_request("GET", "/") + assert response.status == 200 + assert response.headers == {} + assert response.body == b"" + + +#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): + if name: + expected_name = name + expected_group = group + else: + expected_name = "" + expected_group = "Uri" + + @validate_transaction_metrics(name=expected_name, group=expected_group) + def _test(): + asgi_decorator = asgi_application(name=name, group=group) + decorated_application = asgi_decorator(simple_app_v3_raw) + application = AsgiTest(decorated_application) + response = application.make_request("GET", "/") + assert response.status == 200 + assert response.headers == {} + assert response.body == b"" + + _test() + + +#Test asgi_application decorator using default values on a double callable +@validate_transaction_metrics(name="", group="Uri") +def test_asgi_application_decorator_no_params_double_callable(): + asgi_decorator = asgi_application() + decorated_application = asgi_decorator(simple_app_v2_raw) + application = AsgiTest(decorated_application) + response = application.make_request("GET", "/") + assert response.status == 200 + assert response.headers == {} + assert response.body == b"" + + +#Test for presence of framework info based on whether framework is specified +@validate_transaction_metrics(name="test", custom_metrics=[("Python/Framework/framework/v1", 1)]) +def test_framework_metrics(): + asgi_decorator = asgi_application(name="test", framework=("framework", "v1")) + decorated_application = asgi_decorator(simple_app_v2_raw) + application = AsgiTest(decorated_application) + application.make_request("GET", "/") + + +@pytest.mark.parametrize("method", ("method", "cls", "static")) +@validate_transaction_metrics(name="", group="Uri") +def test_app_with_descriptor(method): + application = AsgiTest(getattr(AppWithDescriptor(), method)) + response = application.make_request("GET", "/") + assert response.status == 200 + assert response.headers == {} + assert response.body == b"" diff --git a/tests/agent_features/test_asgi_w3c_trace_context.py b/tests/agent_features/test_asgi_w3c_trace_context.py new file mode 100644 index 0000000000..68c192a5d7 --- /dev/null +++ b/tests/agent_features/test_asgi_w3c_trace_context.py @@ -0,0 +1,388 @@ +# 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 pytest + +from newrelic.api.transaction import current_transaction +from newrelic.api.external_trace import ExternalTrace +from newrelic.api.asgi_application import asgi_application + +from testing_support.asgi_testing import AsgiTest +from testing_support.fixtures import (override_application_settings, + validate_transaction_event_attributes, validate_transaction_metrics) +from testing_support.validators.validate_span_events import ( + validate_span_events) + + +@asgi_application() +async def target_asgi_application(scope, receive, send): + status = '200 OK' + type = "http.response.start" + txn = current_transaction() + if txn._sampled is None: + txn._sampled = True + txn._priority = 1.2 + headers = ExternalTrace.generate_request_headers(txn) + response_headers = [] + for key, value in headers: + encoded_key = key.encode('utf-8') + encoded_val = value.encode('utf-8') + response_headers.append((encoded_key, encoded_val)) + + await send({ + "type": type, + "status": status, + "headers": response_headers, + }) + + await send({ + "type": "http.response.body", + "body": b"Hello World", + }) + + return [headers] + + +test_asgi_application = AsgiTest(target_asgi_application) + + +_override_settings = { + 'trusted_account_key': '1', + 'account_id': '1', + 'primary_application_id': '2', + 'distributed_tracing.enabled': True, +} + + +INBOUND_TRACEPARENT_ZERO_PARENT_ID = \ + '00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01' +INBOUND_TRACEPARENT_ZERO_TRACE_ID = \ + '00-00000000000000000000000000000000-00f067aa0ba902b7-01' +INBOUND_TRACEPARENT = \ + '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01' +INBOUND_TRACEPARENT_NEW_VERSION_EXTRA_FIELDS = \ + '01-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01-extra-field' +INBOUND_TRACEPARENT_VERSION_ZERO_EXTRA_FIELDS = \ + '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01-extra-field' +INBOUND_TRACESTATE = \ + 'rojo=f06a0ba902b7,congo=t61rcWkgMzE' +LONG_TRACESTATE = \ + ','.join(["{}@rojo=f06a0ba902b7".format(x) for x in range(32)]) +INBOUND_UNTRUSTED_NR_TRACESTATE = \ + ('2@nr=0-0-1345936-55632452-27jjj2d8890283b4-b28ce285632jjhl9-' + '1-1.1273-1569367663277') +INBOUND_NR_TRACESTATE = \ + ('1@nr=0-0-1349956-41346604-27ddd2d8890283b4-b28be285632bbc0a-' + '1-1.4-1569367663277') +INBOUND_NR_TRACESTATE_UNSAMPLED = \ + ('1@nr=0-0-1349956-41346604-27ddd2d8890283b4-b28be285632bbc0a-' + '0-0.4-1569367663277') +INBOUND_NR_TRACESTATE_INVALID_TIMESTAMP = \ + ('1@nr=0-0-1349956-41346604-27ddd2d8890283b4-b28be285632bbc0a-' + '0-0.4-timestamp') +INBOUND_NR_TRACESTATE_INVALID_PARENT_TYPE = \ + ('1@nr=0-parentType-1349956-41346604-' + '27ddd2d8890283b4-b28be285632bbc0a-1-1.4-1569367663277') + +INBOUND_TRACEPARENT_VERSION_FF = \ + 'ff-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01' +INBOUND_TRACEPARENT_VERSION_TOO_LONG = \ + '000-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01' +INBOUND_TRACEPARENT_INVALID_TRACE_ID = \ + '00-0aF7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01' +INBOUND_TRACEPARENT_INVALID_PARENT_ID = \ + '00-0af7651916cd43dd8448eb211c80319c-00f067aa0Ba902b7-01' +INBOUND_TRACEPARENT_INVALID_FLAGS = \ + '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-x1' + +_metrics = [("Supportability/TraceContext/Create/Success", 1)] + + +@pytest.mark.parametrize('inbound_nr_tracestate', (True, False)) +@override_application_settings(_override_settings) +def test_tracestate_generation(inbound_nr_tracestate): + + headers = { + 'traceparent': INBOUND_TRACEPARENT, + 'tracestate': ( + INBOUND_NR_TRACESTATE_UNSAMPLED if inbound_nr_tracestate + else INBOUND_TRACESTATE), + } + + def _test(): + return test_asgi_application.make_request('GET', '/', headers=headers) + + response = _test() + + for header_name, header_value in response.headers.items(): + if header_name == 'tracestate': + break + else: + assert False, 'tracestate header not propagated' + + header_value = header_value.split(',', 1)[0] + key, value = header_value.split('=', 2) + assert key == '1@nr' + + fields = value.split('-') + assert len(fields) == 9 + + assert str(int(fields[8])) == fields[8] + deterministic_fields = fields[:4] + fields[6:8] + assert deterministic_fields == [ + '0', + '0', + '1', + '2', + '0' if inbound_nr_tracestate else '1', + '0.4' if inbound_nr_tracestate else '1.2', + ] + + assert len(fields[4]) == 16 + assert len(fields[5]) == 16 + + +@pytest.mark.parametrize('inbound_tracestate,expected', ( + ('', None), + (INBOUND_NR_TRACESTATE + "," + INBOUND_TRACESTATE, INBOUND_TRACESTATE), + (INBOUND_TRACESTATE, INBOUND_TRACESTATE), + (LONG_TRACESTATE + ',' + INBOUND_NR_TRACESTATE, + ','.join("{}@rojo=f06a0ba902b7".format(x) for x in range(31))), +), ids=( + 'empty_inbound_payload', + 'nr_payload', + 'no_nr_payload', + 'long_payload', +)) +@override_application_settings(_override_settings) +def test_tracestate_propagation(inbound_tracestate, expected): + headers = { + 'traceparent': INBOUND_TRACEPARENT, + 'tracestate': inbound_tracestate + } + response = test_asgi_application.make_request('GET', '/', headers=headers) + for header_name, header_value in response.headers.items(): + if header_name == 'tracestate': + break + else: + assert False, 'tracestate header not propagated' + + assert not header_value.endswith(',') + if inbound_tracestate: + _, propagated_tracestate = header_value.split(',', 1) + assert propagated_tracestate == expected + + +@pytest.mark.parametrize('inbound_traceparent,span_events_enabled', ( + (True, True), + (True, False), + (False, True), +)) +def test_traceparent_generation(inbound_traceparent, span_events_enabled): + settings = _override_settings.copy() + settings['span_events.enabled'] = span_events_enabled + + headers = {} + if inbound_traceparent: + headers['traceparent'] = INBOUND_TRACEPARENT + + @override_application_settings(settings) + def _test(): + return test_asgi_application.make_request('GET', '/', headers=headers) + + response = _test() + for header_name, header_value in response.headers.items(): + if header_name == 'traceparent': + break + else: + assert False, 'traceparent header not present' + + assert len(header_value) == 55 + assert header_value.startswith('00-') + fields = header_value.split('-') + assert len(fields) == 4 + if inbound_traceparent: + assert fields[1] == '0af7651916cd43dd8448eb211c80319c' + assert fields[2] != '00f067aa0ba902b7' + else: + assert len(fields[1]) == 32 + assert fields[3] in ('00', '01') + + +@pytest.mark.parametrize('traceparent,intrinsics,metrics', ( + (INBOUND_TRACEPARENT, { + "traceId": "0af7651916cd43dd8448eb211c80319c", + "parentSpanId": "00f067aa0ba902b7", + "parent.transportType": "HTTP"}, + [("Supportability/TraceContext/TraceParent/Accept/Success", 1)]), + (INBOUND_TRACEPARENT_NEW_VERSION_EXTRA_FIELDS, { + "traceId": "0af7651916cd43dd8448eb211c80319c", + "parentSpanId": "00f067aa0ba902b7", + "parent.transportType": "HTTP"}, + [("Supportability/TraceContext/TraceParent/Accept/Success", 1)]), + (INBOUND_TRACEPARENT + ' ', { + "traceId": "0af7651916cd43dd8448eb211c80319c", + "parentSpanId": "00f067aa0ba902b7", + "parent.transportType": "HTTP"}, + [("Supportability/TraceContext/TraceParent/Accept/Success", 1)]), + + ('INVALID', {}, + [("Supportability/TraceContext/TraceParent/Parse/Exception", 1)]), + ('xx-0', {}, + [("Supportability/TraceContext/TraceParent/Parse/Exception", 1)]), + (INBOUND_TRACEPARENT_VERSION_FF, {}, + [("Supportability/TraceContext/TraceParent/Parse/Exception", 1)]), + (INBOUND_TRACEPARENT_VERSION_ZERO_EXTRA_FIELDS, {}, + [("Supportability/TraceContext/TraceParent/Parse/Exception", 1)]), + (INBOUND_TRACEPARENT[:-1], {}, + [("Supportability/TraceContext/TraceParent/Parse/Exception", 1)]), + ('00-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', {}, + [("Supportability/TraceContext/TraceParent/Parse/Exception", 1)]), + (INBOUND_TRACEPARENT_INVALID_TRACE_ID, {}, + [("Supportability/TraceContext/TraceParent/Parse/Exception", 1)]), + (INBOUND_TRACEPARENT_INVALID_PARENT_ID, {}, + [("Supportability/TraceContext/TraceParent/Parse/Exception", 1)]), + (INBOUND_TRACEPARENT_INVALID_FLAGS, {}, + [("Supportability/TraceContext/TraceParent/Parse/Exception", 1)]), + (INBOUND_TRACEPARENT_ZERO_TRACE_ID, {}, + [("Supportability/TraceContext/TraceParent/Parse/Exception", 1)]), + (INBOUND_TRACEPARENT_ZERO_PARENT_ID, {}, + [("Supportability/TraceContext/TraceParent/Parse/Exception", 1)]), + (INBOUND_TRACEPARENT_VERSION_TOO_LONG, {}, + [("Supportability/TraceContext/TraceParent/Parse/Exception", 1)]), +)) +@override_application_settings(_override_settings) +def test_inbound_traceparent_header(traceparent, intrinsics, metrics): + exact = {'agent': {}, 'user': {}, 'intrinsic': intrinsics} + + @validate_transaction_event_attributes(exact_attrs=exact) + @validate_transaction_metrics( + "", group="Uri", rollup_metrics=metrics) + def _test(): + test_asgi_application.make_request('GET', '/', + headers={"traceparent": traceparent}) + + _test() + + +@pytest.mark.parametrize('tracestate,intrinsics', ( + (INBOUND_TRACESTATE, + {'tracingVendors': 'rojo,congo', + 'parentId': '00f067aa0ba902b7'}), + (INBOUND_NR_TRACESTATE, + {'trustedParentId': '27ddd2d8890283b4'}), + ('garbage', {'parentId': '00f067aa0ba902b7'}), + (INBOUND_TRACESTATE + ',' + INBOUND_NR_TRACESTATE, + {'parentId': '00f067aa0ba902b7', + 'trustedParentId': '27ddd2d8890283b4', + 'tracingVendors': 'rojo,congo'}), + (INBOUND_TRACESTATE + ',' + INBOUND_UNTRUSTED_NR_TRACESTATE, + {'parentId': '00f067aa0ba902b7', + 'tracingVendors': 'rojo,congo,2@nr'}), + ('rojo=12345,' + 'v' * 257 + '=x', + {'tracingVendors': 'rojo'}), + ('rojo=12345,k=' + 'v' * 257, + {'tracingVendors': 'rojo'}), +)) +@override_application_settings(_override_settings) +def test_inbound_tracestate_header(tracestate, intrinsics): + + nr_entry_count = 1 if '1@nr' not in tracestate else None + + metrics = [ + ('Supportability/TraceContext/TraceState/NoNrEntry', nr_entry_count), + ] + + @validate_transaction_metrics( + "", group="Uri", rollup_metrics=metrics) + @validate_span_events(exact_intrinsics=intrinsics) + def _test(): + test_asgi_application.make_request('GET', '/', headers={ + "traceparent": INBOUND_TRACEPARENT, + "tracestate": tracestate, + }) + + _test() + + +@override_application_settings(_override_settings) +def test_w3c_tracestate_header(): + + metrics = [ + ('Supportability/TraceContext/Accept/Success', 1), + ] + + @validate_transaction_metrics( + "", group="Uri", rollup_metrics=metrics) + def _test(): + test_asgi_application.make_request('GET', '/', headers={ + "traceparent": INBOUND_TRACEPARENT, + "tracestate": INBOUND_TRACESTATE, + }) + + _test() + + +@pytest.mark.parametrize('tracestate', ( + (INBOUND_NR_TRACESTATE_INVALID_TIMESTAMP), + (INBOUND_NR_TRACESTATE_INVALID_PARENT_TYPE), + )) +@override_application_settings(_override_settings) +def test_invalid_inbound_nr_tracestate_header(tracestate): + + metrics = [ + ('Supportability/TraceContext/TraceState/InvalidNrEntry', 1) + ] + + @validate_transaction_metrics( + "", group="Uri", rollup_metrics=metrics) + def _test(): + test_asgi_application.make_request('GET', '/', headers={ + "traceparent": INBOUND_TRACEPARENT, + "tracestate": tracestate, + }) + + _test() + + +@pytest.mark.parametrize('exclude_newrelic_header', (True, False)) +def test_w3c_and_newrelic_headers_generated(exclude_newrelic_header): + + settings = _override_settings.copy() + settings['distributed_tracing.exclude_newrelic_header'] = \ + exclude_newrelic_header + + @override_application_settings(settings) + def _test(): + return test_asgi_application.make_request('GET', '/') + + response = _test() + traceparent = None + tracestate = None + newrelic = None + for header_name, header_value in response.headers.items(): + if header_name == 'traceparent': + traceparent = header_value + elif header_name == 'tracestate': + tracestate = header_value + elif header_name == 'newrelic': + newrelic = header_value + + assert traceparent + assert tracestate + + if exclude_newrelic_header: + assert newrelic is None + else: + assert newrelic diff --git a/tests/agent_features/test_attributes_in_action.py b/tests/agent_features/test_attributes_in_action.py index dd4946aef7..9384ff3f58 100644 --- a/tests/agent_features/test_attributes_in_action.py +++ b/tests/agent_features/test_attributes_in_action.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pytest import webtest from newrelic.api.application import application_instance as application @@ -32,12 +33,19 @@ from testing_support.validators.validate_span_events import ( validate_span_events) +try: + from testing_support.sample_asgi_applications import normal_asgi_application + from testing_support.asgi_testing import AsgiTest +except SyntaxError: + normal_asgi_application = None + URL_PARAM = 'some_key' URL_PARAM2 = 'second_key' REQUEST_URL = '/?' + URL_PARAM + '=someval&' + URL_PARAM2 + '=anotherval' REQUEST_HEADERS = [ ('Accept', '*/*'), + ('Host', 'foobar'), ('User-Agent', 'test_attributes_in_action'), ('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', '10'), ] @@ -51,7 +59,7 @@ USER_ATTRS = ['puppies', 'sunshine'] -TRACE_ERROR_AGENT_KEYS = ['wsgi.output.seconds', 'response.status', +TRACE_ERROR_AGENT_KEYS = ['response.status', 'request.method', 'request.headers.contentType', 'request.uri', 'request.headers.accept', 'request.headers.contentLength', 'request.headers.host', 'request.headers.userAgent', @@ -99,7 +107,17 @@ def normal_wsgi_application(environ, start_response): return [output] -normal_application = webtest.TestApp(normal_wsgi_application) +application_params=[normal_wsgi_application] +if normal_asgi_application: + application_params.append(normal_asgi_application) + + +@pytest.fixture(scope="module", params=application_params) +def normal_application(request): + if request.param is normal_wsgi_application: + return webtest.TestApp(normal_wsgi_application) + else: + return AsgiTest(normal_asgi_application) # Tests for checking the presence and format of agent attributes. # Test default settings. @@ -118,7 +136,7 @@ def normal_wsgi_application(environ, start_response): _expected_absent_attributes) @validate_transaction_error_trace_attributes(_expected_attributes, _expected_absent_attributes) -def test_error_in_transaction_default_settings(): +def test_error_in_transaction_default_settings(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -129,27 +147,27 @@ def test_error_in_transaction_default_settings(): @validate_transaction_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings({}) -def test_transaction_trace_default_attribute_settings(): +def test_transaction_trace_default_attribute_settings(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) _expected_attributes = {'agent': TRANS_EVENT_AGENT_KEYS, 'user': USER_ATTRS, 'intrinsic': TRANS_EVENT_INTRINSICS} -_expected_absent_attributes = {'agent': ['wsgi.output.seconds'] + REQ_PARAMS, +_expected_absent_attributes = {'agent': REQ_PARAMS, 'user': [], 'intrinsic': []} @validate_transaction_event_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings({}) -def test_transaction_event_default_attribute_settings(): +def test_transaction_event_default_attribute_settings(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @dt_enabled @validate_span_events(expected_users=_expected_attributes['user']) -def test_root_span_default_attribute_settings(): +def test_root_span_default_attribute_settings(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) # Browser monitoring off by default, turn on and check default destinations @@ -165,7 +183,7 @@ def test_root_span_default_attribute_settings(): @validate_browser_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_browser_default_attribute_settings(): +def test_browser_default_attribute_settings(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -189,7 +207,7 @@ def test_browser_default_attribute_settings(): @validate_transaction_error_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_error_in_transaction_exclude_request_params(): +def test_error_in_transaction_exclude_request_params(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -203,7 +221,7 @@ def test_error_in_transaction_exclude_request_params(): @validate_transaction_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_trace_exclude_request_params(): +def test_transaction_trace_exclude_request_params(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -220,7 +238,7 @@ def test_transaction_trace_exclude_request_params(): @validate_transaction_error_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_error_in_transaction_capture_params_exclude_request_params(): +def test_error_in_transaction_capture_params_exclude_request_params(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -231,7 +249,7 @@ def test_error_in_transaction_capture_params_exclude_request_params(): @validate_transaction_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_trace_capture_params_exclude_request_params(): +def test_transaction_trace_capture_params_exclude_request_params(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -250,7 +268,7 @@ def test_transaction_trace_capture_params_exclude_request_params(): @validate_error_event_attributes(_expected_attributes_event) @validate_transaction_error_trace_attributes(_expected_attributes) @override_application_settings(_override_settings) -def test_error_in_transaction_include_request_params(): +def test_error_in_transaction_include_request_params(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -263,7 +281,7 @@ def test_error_in_transaction_include_request_params(): @validate_transaction_trace_attributes(_expected_attributes) @override_application_settings(_override_settings) -def test_transaction_trace_include_request_params(): +def test_transaction_trace_include_request_params(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -281,7 +299,7 @@ def test_transaction_trace_include_request_params(): @validate_transaction_event_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_event_include_request_params(): +def test_transaction_event_include_request_params(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -296,7 +314,7 @@ def test_transaction_event_include_request_params(): @validate_browser_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_browser_include_request_params(): +def test_browser_include_request_params(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -324,7 +342,7 @@ def test_browser_include_request_params(): @validate_transaction_error_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_error_in_transaction_include_exclude(): +def test_error_in_transaction_include_exclude(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -342,7 +360,7 @@ def test_error_in_transaction_include_exclude(): @validate_transaction_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_trace_include_exclude(): +def test_transaction_trace_include_exclude(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -362,7 +380,7 @@ def test_transaction_trace_include_exclude(): @validate_transaction_event_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_event_include_exclude(): +def test_transaction_event_include_exclude(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -380,7 +398,7 @@ def test_transaction_event_include_exclude(): @validate_browser_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_browser_include_exclude_request_params(): +def test_browser_include_exclude_request_params(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -403,7 +421,7 @@ def test_browser_include_exclude_request_params(): @validate_transaction_error_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_error_in_transaction_exclude_user_attribute(): +def test_error_in_transaction_exclude_user_attribute(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -416,7 +434,7 @@ def test_error_in_transaction_exclude_user_attribute(): @validate_transaction_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_trace_exclude_user_attribute(): +def test_transaction_trace_exclude_user_attribute(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -432,7 +450,7 @@ def test_transaction_trace_exclude_user_attribute(): @validate_transaction_event_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_event_exclude_user_attribute(): +def test_transaction_event_exclude_user_attribute(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -444,7 +462,7 @@ def test_transaction_event_exclude_user_attribute(): @validate_span_events( expected_users=_expected_attributes['user'], unexpected_users=_expected_absent_attributes['user']) -def test_span_event_exclude_user_attribute(): +def test_span_event_exclude_user_attribute(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -460,7 +478,7 @@ def test_span_event_exclude_user_attribute(): @validate_browser_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_browser_exclude_user_attribute(): +def test_browser_exclude_user_attribute(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -469,11 +487,11 @@ def test_browser_exclude_user_attribute(): _override_settings = {'attributes.exclude': ['request.*'], 'attributes.include': ['request.headers.*']} -_expected_attributes = {'agent': ['wsgi.output.seconds', 'response.status', +_expected_attributes = {'agent': ['response.status', 'request.headers.contentType', 'request.headers.contentLength'], 'user': ERROR_USER_ATTRS, 'intrinsic': ['trip_id']} -_expected_attributes_event = {'agent': ['wsgi.output.seconds', +_expected_attributes_event = {'agent': [ 'response.status', 'request.headers.contentType', 'request.headers.contentLength'], 'user': ERROR_USER_ATTRS, 'intrinsic': ERROR_EVENT_INTRINSICS} @@ -488,11 +506,11 @@ def test_browser_exclude_user_attribute(): @validate_transaction_error_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_error_in_transaction_exclude_agent_attribute(): +def test_error_in_transaction_exclude_agent_attribute(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) -_expected_attributes = {'agent': ['wsgi.output.seconds', 'response.status', +_expected_attributes = {'agent': [ 'response.status', 'request.headers.contentType', 'request.headers.contentLength'], 'user': USER_ATTRS, 'intrinsic': ['trip_id']} @@ -500,7 +518,7 @@ def test_error_in_transaction_exclude_agent_attribute(): @validate_transaction_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_trace_exclude_agent_attribute(): +def test_transaction_trace_exclude_agent_attribute(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -509,21 +527,21 @@ def test_transaction_trace_exclude_agent_attribute(): 'user': USER_ATTRS, 'intrinsic': TRANS_EVENT_INTRINSICS} _expected_absent_attributes = {'agent': ['request.method', - 'wsgi.output.seconds', 'request.uri'] + REQ_PARAMS, 'user': [], + 'request.uri'] + REQ_PARAMS, 'user': [], 'intrinsic': []} @validate_transaction_event_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_event_exclude_agent_attribute(): +def test_transaction_event_exclude_agent_attribute(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) _override_settings = {'attributes.exclude': ['request.*'], - 'attributes.include': ['request.headers.*', 'wsgi.*']} + 'attributes.include': ['request.headers.*']} -_expected_agent_attributes = ['wsgi.output.seconds', 'response.status', +_expected_agent_attributes = ['response.status', 'request.headers.contentType', 'request.headers.contentLength'] _expected_absent_agent_attributes = ['request.method', @@ -535,7 +553,7 @@ def test_transaction_event_exclude_agent_attribute(): @validate_span_events( expected_agents=_expected_agent_attributes, unexpected_agents=_expected_absent_agent_attributes) -def test_span_event_exclude_agent_attribute(): +def test_span_event_exclude_agent_attribute(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -556,7 +574,7 @@ def test_span_event_exclude_agent_attribute(): @validate_error_event_attributes(_expected_attributes_event) @validate_transaction_error_trace_attributes(_expected_attributes) @override_application_settings(_override_settings) -def test_error_in_transaction_deprecated_capture_params_true(): +def test_error_in_transaction_deprecated_capture_params_true(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -566,7 +584,7 @@ def test_error_in_transaction_deprecated_capture_params_true(): @validate_transaction_trace_attributes(_expected_attributes) @override_application_settings(_override_settings) -def test_transaction_trace_deprecated_capture_params_true(): +def test_transaction_trace_deprecated_capture_params_true(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -582,7 +600,7 @@ def test_transaction_trace_deprecated_capture_params_true(): @validate_transaction_event_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_event_deprecated_capture_params_true(): +def test_transaction_event_deprecated_capture_params_true(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -598,7 +616,7 @@ def test_transaction_event_deprecated_capture_params_true(): @validate_browser_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_browser_deprecated_capture_params_true(): +def test_browser_deprecated_capture_params_true(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -621,7 +639,7 @@ def test_browser_deprecated_capture_params_true(): @validate_transaction_error_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_error_in_transaction_deprecated_capture_params_false(): +def test_error_in_transaction_deprecated_capture_params_false(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -632,7 +650,7 @@ def test_error_in_transaction_deprecated_capture_params_false(): @validate_transaction_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_trace_deprecated_capture_params_false(): +def test_transaction_trace_deprecated_capture_params_false(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -648,7 +666,7 @@ def test_transaction_trace_deprecated_capture_params_false(): @validate_transaction_event_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_event_deprecated_capture_params_false(): +def test_transaction_event_deprecated_capture_params_false(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -664,7 +682,7 @@ def test_transaction_event_deprecated_capture_params_false(): @validate_browser_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_browser_deprecated_capture_params_false(): +def test_browser_deprecated_capture_params_false(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -687,7 +705,7 @@ def test_browser_deprecated_capture_params_false(): @validate_transaction_error_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_error_in_transaction_exclude_intrinsic(): +def test_error_in_transaction_exclude_intrinsic(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -700,7 +718,7 @@ def test_error_in_transaction_exclude_intrinsic(): @validate_transaction_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_trace_exclude_intrinsic(): +def test_transaction_trace_exclude_intrinsic(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -717,7 +735,7 @@ def test_transaction_trace_exclude_intrinsic(): @validate_transaction_event_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_event_exclude_intrinsic(): +def test_transaction_event_exclude_intrinsic(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -733,7 +751,7 @@ def test_transaction_event_exclude_intrinsic(): @validate_browser_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_browser_exclude_intrinsic(): +def test_browser_exclude_intrinsic(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -755,7 +773,7 @@ def test_browser_exclude_intrinsic(): @validate_transaction_error_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_error_in_transaction_attributes_disabled(): +def test_error_in_transaction_attributes_disabled(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -765,7 +783,7 @@ def test_error_in_transaction_attributes_disabled(): @validate_transaction_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_trace_attributes_disabled(): +def test_transaction_trace_attributes_disabled(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -781,7 +799,7 @@ def test_transaction_trace_attributes_disabled(): @validate_transaction_event_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_transaction_event_attributes_disabled(): +def test_transaction_event_attributes_disabled(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -796,7 +814,7 @@ def test_transaction_event_attributes_disabled(): @validate_browser_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings({}) -def test_browser_attributes_disabled(): +def test_browser_attributes_disabled(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -819,7 +837,7 @@ def test_browser_attributes_disabled(): @validate_transaction_error_trace_attributes(_expected_attributes, _expected_absent_attributes) @override_application_settings(_override_settings) -def test_error_in_transaction_error_param_excluded(): +def test_error_in_transaction_error_param_excluded(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) @@ -834,7 +852,7 @@ def test_error_in_transaction_error_param_excluded(): @validate_transaction_trace_attributes(_expected_attributes) @validate_transaction_event_attributes(_expected_attributes) @override_application_settings(_override_settings) -def test_browser_monitoring_disabled(): +def test_browser_monitoring_disabled(normal_application): normal_application.get(REQUEST_URL, headers=REQUEST_HEADERS) diff --git a/tests/agent_features/test_wsgi_attributes.py b/tests/agent_features/test_wsgi_attributes.py new file mode 100644 index 0000000000..0f7f7d6f29 --- /dev/null +++ b/tests/agent_features/test_wsgi_attributes.py @@ -0,0 +1,49 @@ +# 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 webtest +from testing_support.fixtures import ( + dt_enabled, + override_application_settings, + validate_error_event_attributes, + validate_transaction_error_trace_attributes, + validate_transaction_event_attributes, +) +from testing_support.sample_applications import fully_featured_app + +WSGI_ATTRIBUTES = [ + "wsgi.input.seconds", + "wsgi.input.bytes", + "wsgi.input.calls.read", + "wsgi.input.calls.readline", + "wsgi.input.calls.readlines", + "wsgi.output.seconds", + "wsgi.output.bytes", + "wsgi.output.calls.write", + "wsgi.output.calls.yield", +] +required_attributes = {"agent": WSGI_ATTRIBUTES, "intrinsic": {}, "user": {}} + +app = webtest.TestApp(fully_featured_app) + + +@validate_transaction_event_attributes(required_attributes) +@validate_error_event_attributes(required_attributes) +@validate_transaction_error_trace_attributes(required_attributes) +@override_application_settings({"attributes.include": ["*"]}) +@dt_enabled +def test_wsgi_attributes(): + app.post_json( + "/", {"foo": "bar"}, extra_environ={"n_errors": "1", "err_message": "oops"} + ) diff --git a/tests/agent_features/tox.ini b/tests/agent_features/tox.ini index bf23888391..deac6abfdc 100644 --- a/tests/agent_features/tox.ini +++ b/tests/agent_features/tox.ini @@ -14,6 +14,8 @@ setenv = TOX_ENVDIR = {envdir} without-extensions: NEW_RELIC_EXTENSIONS = false with-extensions: NEW_RELIC_EXTENSIONS = true +deps = + beautifulsoup4 passenv = NEW_RELIC_LICENSE_KEY diff --git a/tests/testing_support/asgi_testing.py b/tests/testing_support/asgi_testing.py new file mode 100644 index 0000000000..747b0e3247 --- /dev/null +++ b/tests/testing_support/asgi_testing.py @@ -0,0 +1,128 @@ +# 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 +from enum import Enum + + +class ResponseState(Enum): + NOT_STARTED = 1 + BODY = 2 + DONE = 3 + + +class Response(object): + def __init__(self, status, headers, body): + self.status = status + self.headers = headers + self.body = b"".join(body) + + +class AsgiTest(object): + def __init__(self, asgi_application): + self.asgi_application = asgi_application + + def make_request(self, method, path, params=None, headers=None, body=None): + loop = asyncio.get_event_loop() + coro = self.make_request_async(method, path, params, headers, body) + return loop.run_until_complete(coro) + + def get(self, path, params=None, headers=None, body=None): + return self.make_request("GET", path, params, headers, body) + + async def make_request_async(self, method, path, params, headers, body): + self.input_queue = asyncio.Queue() + self.output_queue = asyncio.Queue() + self.response_state = ResponseState.NOT_STARTED + + scope = self.generate_input(method, path, params, headers, body) + + try: + awaitable = self.asgi_application( + scope, self.input_queue.get, self.output_queue.put + ) + except TypeError: + instance = self.asgi_application(scope) + awaitable = instance(self.input_queue.get, self.output_queue.put) + + await awaitable + return self.process_output() + + def generate_input(self, method, path, params, headers, body): + headers_list = [] + if headers: + try: + iterable = headers.items() + except AttributeError: + iterable = iter(headers) + + for key, value in iterable: + header_tuple = (key.lower().encode("utf-8"), value.encode("utf-8")) + headers_list.append(header_tuple) + if not params: + path, _, params = path.partition("?") + + scope = { + "asgi": {"spec_version": "2.1", "version": "3.0"}, + "client": ("127.0.0.1", 54768), + "headers": headers_list, + "http_version": "1.1", + "method": method.upper(), + "path": path, + "query_string": params and params.encode("utf-8") or b"", + "raw_path": path.encode("utf-8"), + "root_path": "", + "scheme": "http", + "server": ("127.0.0.1", 8000), + "type": "http", + } + message = {"type": "http.request"} + if body: + message["body"] = body.encode("utf-8") + + self.input_queue.put_nowait(message) + return scope + + def process_output(self): + response_status = None + response_headers = [] + body = [] + + while True: + try: + message = self.output_queue.get_nowait() + except asyncio.QueueEmpty: + break + + 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) + self.response_state = ResponseState.BODY + elif self.response_state is ResponseState.BODY: + assert message["type"] == "http.response.body" + body.append(message.get("body", b"")) + + more_body = message.get("more_body", False) + if not more_body: + self.response_state = ResponseState.DONE + else: + assert False, "ASGI protocol error: unexpected message" + + assert ( + self.response_state is ResponseState.DONE + ), "ASGI protocol error: state is not DONE, expected additional messages" + final_headers = {k.decode("utf-8"): v.decode("utf-8") for k, v in response_headers} + assert len(final_headers) == len(response_headers), "Duplicate header names are not supported" + return Response(response_status, final_headers, body) diff --git a/tests/testing_support/sample_applications.py b/tests/testing_support/sample_applications.py index 311bd4a62d..3da1b50090 100644 --- a/tests/testing_support/sample_applications.py +++ b/tests/testing_support/sample_applications.py @@ -66,6 +66,10 @@ def fully_featured_app(environ, start_response): path = environ.get('PATH_INFO') use_user_attrs = environ.get('record_attributes', 'TRUE') == 'TRUE' + environ['wsgi.input'].read() + environ['wsgi.input'].readline() + environ['wsgi.input'].readlines() + if use_user_attrs: for attr, val in _custom_parameters.items(): @@ -103,7 +107,9 @@ def fully_featured_app(environ, start_response): response_headers = [('Content-type', 'text/html; charset=utf-8'), ('Content-Length', str(len(output)))] - start_response(status, response_headers) + write = start_response(status, response_headers) + + write(b'') return [output] diff --git a/tests/testing_support/sample_asgi_applications.py b/tests/testing_support/sample_asgi_applications.py new file mode 100644 index 0000000000..372001906a --- /dev/null +++ b/tests/testing_support/sample_asgi_applications.py @@ -0,0 +1,76 @@ +# 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.time_trace import record_exception +from newrelic.api.transaction import add_custom_parameter +from newrelic.api.asgi_application import ASGIApplicationWrapper + + +class simple_app_v2_raw: + def __init__(self, scope): + assert scope["type"] == "http" + + async def __call__(self, receive, send): + await send({"type": "http.response.start", "status": 200}) + await send({"type": "http.response.body"}) + + +async def simple_app_v3_raw(scope, receive, send): + if scope["type"] != "http": + raise ValueError("unsupported") + + await send({"type": "http.response.start", "status": 200}) + await send({"type": "http.response.body"}) + + +class AppWithDescriptor: + @ASGIApplicationWrapper + async def method(self, scope, receive, send): + return await simple_app_v3_raw(scope, receive, send) + + @ASGIApplicationWrapper + @classmethod + async def cls(cls, scope, receive, send): + return await simple_app_v3_raw(scope, receive, send) + + @ASGIApplicationWrapper + @staticmethod + async def static(scope, receive, send): + return await simple_app_v3_raw(scope, receive, send) + + +simple_app_v2 = ASGIApplicationWrapper(simple_app_v2_raw) +simple_app_v3 = ASGIApplicationWrapper(simple_app_v3_raw) + + +@ASGIApplicationWrapper +async def normal_asgi_application(scope, receive, send): + output = b"header

RESPONSE

" + add_custom_parameter("puppies", "test_value") + add_custom_parameter("sunshine", "test_value") + + response_headers = [ + (b"content-type", b"text/html; charset=utf-8"), + (b"content-length", str(len(output)).encode("utf-8")), + ] + + try: + raise ValueError('Transaction had bad value') + except ValueError: + record_exception(params={'ohnoes': 'param-value'}) + + await send( + {"type": "http.response.start", "status": 200, "headers": response_headers} + ) + await send({"type": "http.response.body", "body": output})