diff --git a/newrelic/config.py b/newrelic/config.py index 0b2ad7356..af61d292a 100644 --- a/newrelic/config.py +++ b/newrelic/config.py @@ -215,6 +215,10 @@ def _map_inc_excl_attributes(s): return newrelic.core.config._parse_attributes(s) +def _map_inc_excl_middleware(s): + return newrelic.core.config._parse_django_middleware(s) + + def _map_case_insensitive_excl_labels(s): return [v.lower() for v in newrelic.core.config._parse_attributes(s)] @@ -510,7 +514,9 @@ def _process_configuration(section): section, "instrumentation.kombu.ignored_exchanges", "get", newrelic.core.config.parse_space_separated_into_list ) _process_setting(section, "instrumentation.kombu.consumer.enabled", "getboolean", None) - + _process_setting(section, "instrumentation.django_middleware.enabled", "getboolean", None) + _process_setting(section, "instrumentation.django_middleware.exclude", "get", _map_inc_excl_middleware) + _process_setting(section, "instrumentation.django_middleware.include", "get", _map_inc_excl_middleware) # Loading of configuration from specified file and for specified # deployment environment. Can also indicate whether configuration diff --git a/newrelic/core/config.py b/newrelic/core/config.py index 3ee20cd67..ae692e5c4 100644 --- a/newrelic/core/config.py +++ b/newrelic/core/config.py @@ -320,6 +320,10 @@ class SpanEventAttributesSettings(Settings): pass +class InstrumentationDjangoMiddlewareSettings(Settings): + pass + + class DistributedTracingSettings(Settings): pass @@ -507,6 +511,7 @@ class EventHarvestConfigHarvestLimitSettings(Settings): _settings.instrumentation.kombu = InstrumentationKombuSettings() _settings.instrumentation.kombu.ignored_exchanges = InstrumentationKombuIgnoredExchangesSettings() _settings.instrumentation.kombu.consumer = InstrumentationKombuConsumerSettings() +_settings.instrumentation.django_middleware = InstrumentationDjangoMiddlewareSettings() _settings.message_tracer = MessageTracerSettings() _settings.process_host = ProcessHostSettings() _settings.rum = RumSettings() @@ -644,6 +649,17 @@ def _parse_attributes(s): return valid +# Called from newrelic.config.py to parse django_middleware lists +def _parse_django_middleware(s): + valid = [] + for item in s.split(): + if "*" not in item[:-1] and len(item.encode("utf-8")) < 256: + valid.append(item) + else: + _logger.warning("Improperly formatted middleware: %r", item) + return valid + + def default_host(license_key): if not license_key: return "collector.newrelic.com" @@ -904,6 +920,10 @@ def default_otlp_host(host): "NEW_RELIC_INSTRUMENTATION_KOMBU_CONSUMER_ENABLED", default=False ) +_settings.instrumentation.django_middleware.enabled = _environ_as_bool("NEW_RELIC_INSTRUMENTATION_DJANGO_MIDDLEWARE_ENABLED", default=True) +_settings.instrumentation.django_middleware.exclude = [] +_settings.instrumentation.django_middleware.include = [] + _settings.event_harvest_config.harvest_limits.analytic_event_data = _environ_as_int( "NEW_RELIC_ANALYTICS_EVENTS_MAX_SAMPLES_STORED", DEFAULT_RESERVOIR_SIZE ) diff --git a/newrelic/hooks/framework_django.py b/newrelic/hooks/framework_django.py index d0a5f7747..d9ce55cf4 100644 --- a/newrelic/hooks/framework_django.py +++ b/newrelic/hooks/framework_django.py @@ -18,7 +18,7 @@ import threading import warnings -from newrelic.api.application import register_application +from newrelic.api.application import register_application, application_settings from newrelic.api.background_task import BackgroundTaskWrapper from newrelic.api.error_trace import wrap_error_trace from newrelic.api.function_trace import FunctionTrace, FunctionTraceWrapper, wrap_function_trace @@ -238,77 +238,12 @@ def wrapper(wrapped, instance, args, kwargs): return FunctionWrapper(wrapped, wrapper) for wrapped in middleware: - yield wrapper(wrapped) - - -# Because this is not being used in any version of Django that is -# within New Relic's support window, no tests will be added -# for this. However, value exists to keeping backwards compatible -# functionality, so instead of removing this instrumentation, this -# will be excluded from the coverage analysis. -def wrap_view_middleware(middleware): # pragma: no cover - # This is no longer being used. The changes to strip the - # wrapper from the view handler when passed into the function - # urlresolvers.reverse() solves most of the problems. To back - # that up, the object wrapper now proxies various special - # methods so that comparisons like '==' will work. The object - # wrapper can even be used as a standin for the wrapped object - # when used as a key in a dictionary and will correctly match - # the original wrapped object. - - # Wrapper to be applied to view middleware. Records the time - # spent in the middleware as separate function node and also - # attempts to name the web transaction after the name of the - # middleware with success being determined by the priority. - # This wrapper is special in that it must strip the wrapper - # from the view handler when being passed to the view - # middleware to avoid issues where middleware wants to do - # comparisons between the passed middleware and some other - # value. It is believed that the view handler should never - # actually be called from the view middleware so not an - # issue that no longer wrapped at this point. + do_not_wrap = is_denied_middleware(callable_name(wrapped)) - def wrapper(wrapped): - # The middleware if a class method would already be - # bound at this point, so is safe to determine the name - # when it is being wrapped rather than on each - # invocation. - - name = callable_name(wrapped) - - def wrapper(wrapped, instance, args, kwargs): - transaction = current_transaction() - - def _wrapped(request, view_func, view_args, view_kwargs): - # This strips the view handler wrapper before call. - - if hasattr(view_func, "_nr_last_object"): - view_func = view_func._nr_last_object - - return wrapped(request, view_func, view_args, view_kwargs) - - if transaction is None: - return _wrapped(*args, **kwargs) - - before = (transaction.name, transaction.group) - - with FunctionTrace(name=name, source=wrapped): - try: - return _wrapped(*args, **kwargs) - - finally: - # We want to name the transaction after this - # middleware but only if the transaction wasn't - # named from within the middleware itself explicitly. - - after = (transaction.name, transaction.group) - if before == after: - transaction.set_transaction_name(name, priority=2) - - return FunctionWrapper(wrapped, wrapper) - - for wrapped in middleware: - yield wrapper(wrapped) + if do_not_wrap: + yield wrapped + else: + yield wrapper(wrapped) def wrap_trailing_middleware(middleware): @@ -323,7 +258,13 @@ def wrap_trailing_middleware(middleware): # invocation. for wrapped in middleware: - yield FunctionTraceWrapper(wrapped, name=callable_name(wrapped)) + name = callable_name(wrapped) + do_not_wrap = is_denied_middleware(name) + + if do_not_wrap: + yield wrapped + else: + yield FunctionTraceWrapper(wrapped, name=name) def insert_and_wrap_middleware(handler, *args, **kwargs): @@ -1149,6 +1090,109 @@ def _wrapper(wrapped, instance, args, kwargs): return _wrapper(middleware) +# For faster logic, could we cache the callable_names to +# avoid this logic for multiple calls to the same middleware? +# Also, is that even a scenario we need to worry about? +def is_denied_middleware(callable_name): + settings = application_settings() or global_settings() + + # Return True (skip wrapping) if: + # 1. middleware wrapping is disabled or + # 2. the callable name is in the exclude list + if ( + not settings.instrumentation.django_middleware.enabled + or (callable_name in settings.instrumentation.django_middleware.exclude) + ): + return True + + # Return False (wrap) if: + # 1. If the callable name is in the include list, wrap this. + # If we have made it to this point in the logic, that means + # that the callable name is not explicitly in the exclude + # list and, whether it is in the exclude list as a wildcard + # or not, the list of middleware to include explicitly takes + # precedence over the exclude list's wildcards. + # 2. enabled=True and len(exclude)==0 + # This scenario is redundant logic, but we utilize it to + # speed up the logic for the common case where + # the user has not specified any include or exclude lists. + if ((callable_name in settings.instrumentation.django_middleware.include) + or (settings.instrumentation.django_middleware.enabled + and len(settings.instrumentation.django_middleware.exclude) == 0)): + return False + + # ========================================================= + # Wildcard logic for include and exclude lists: + # ========================================================= + # Returns False if an entry in the include wildcard is more + # specific than exclude wildcard. Otherwise, it will iterate + # through the entire include list and return True if no more + # specific include (than exclude) is found. + def include_logic(callable_name, exclude_middleware_name): + """ + We iterate through the entire include and exclude lists to + ensure that the user has not included overlapping wildcards + that would potentially be less specific than the exclude in + one case and more specific than the exclude in another case. + + e.g.: + exclude = middleware.*, middleware.parameters.bar* + include = middleware.parameters.* + + Where `middleware.*` is less specific than `middleware.parameters.*` + but `middleware.parameters.bar*` is more specific than `middleware.parameters.*` + + In this case, we want to make sure to exclude any middleware that + matches the `middleware.parameters.bar*` pattern, but include any + other middleware that matches the `middleware.parameters.*` pattern. + """ + if len(settings.instrumentation.django_middleware.include) == 0: + return True + for include_middleware in settings.instrumentation.django_middleware.include: + if include_middleware.endswith("*"): + include_middleware_name = include_middleware.rstrip("*") + if callable_name.startswith(include_middleware_name): + # Count number of dots in the include and exclude + # middleware names to determine specificity. + exclude_dots = exclude_middleware_name.count(".") + include_dots = include_middleware_name.count(".") + if include_dots > exclude_dots: + # Include this middleware--include is more specific and takes precedence + return False + elif include_dots == exclude_dots: + # Count number of characters after the last dot + exclude_post_dot_char_count = len(exclude_middleware_name) - exclude_middleware_name.rfind(".") + include_post_dot_char_count = len(include_middleware_name) - include_middleware_name.rfind(".") + if include_post_dot_char_count > exclude_post_dot_char_count: + # Include this middleware--include is more specific and takes precedence + return False + else: + # Exclude wildcard has the same or greater specificity than the include wildcard + # Expect to exclude this middleware--so far, exclude is more specific and takes precedence + continue + else: + # Expect to exclude this middleware--so far, exclude is more specific and takes precedence + pass + + # if we have made it to this point, there is no include middleware that is + # more specific than the exclude middleware, so we can safely return True + return True + + # Check if the callable name matches any of the excluded middleware patterns. + # If middleware name ends with '*', it is a wildcard + deny = False + for exclude_middleware in settings.instrumentation.django_middleware.exclude: + if exclude_middleware.endswith("*"): + name = exclude_middleware.rstrip("*") + if callable_name.startswith(name): + if not include_logic(callable_name, name): + return False + else: + deny |= include_logic(callable_name, name) + + return deny + + def _nr_wrapper_convert_exception_to_response_(wrapped, instance, args, kwargs): def _bind_params(original_middleware, *args, **kwargs): return original_middleware @@ -1156,10 +1200,14 @@ def _bind_params(original_middleware, *args, **kwargs): original_middleware = _bind_params(*args, **kwargs) converted_middleware = wrapped(*args, **kwargs) name = callable_name(original_middleware) + do_not_wrap = is_denied_middleware(name) - if is_coroutine_function(converted_middleware) or is_asyncio_coroutine(converted_middleware): - return _nr_wrap_converted_middleware_async_(converted_middleware, name) - return _nr_wrap_converted_middleware_(converted_middleware, name) + if do_not_wrap: + return converted_middleware + else: + if is_coroutine_function(converted_middleware) or is_asyncio_coroutine(converted_middleware): + return _nr_wrap_converted_middleware_async_(converted_middleware, name) + return _nr_wrap_converted_middleware_(converted_middleware, name) def instrument_django_core_handlers_exception(module): diff --git a/tests/framework_django/conftest.py b/tests/framework_django/conftest.py index 703ede617..804733e04 100644 --- a/tests/framework_django/conftest.py +++ b/tests/framework_django/conftest.py @@ -12,20 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pytest +from newrelic.core.agent import agent_instance +from newrelic.api.application import application_instance -from testing_support.fixtures import collector_agent_registration_fixture, collector_available_fixture - -_default_settings = { - "package_reporting.enabled": False, # Turn off package reporting for testing as it causes slow downs. - "transaction_tracer.explain_threshold": 0.0, - "transaction_tracer.transaction_threshold": 0.0, - "transaction_tracer.stack_trace_threshold": 0.0, - "debug.log_data_collector_payloads": True, - "debug.record_transaction_failure": True, - "debug.log_autorum_middleware": True, - "feature_flag": {"django.instrumentation.inclusion-tags.r1"}, -} - -collector_agent_registration = collector_agent_registration_fixture( - app_name="Python Agent Test (framework_django)", default_settings=_default_settings -) +# Even though `django_collector_agent_registration_fixture()` also +# has the application deletion during the breakdown of the fixture, +# and `django_collector_agent_registration_fixture()` is scoped to +# "function", not all modules are using this. Some are using +# `collector_agent_registration_fixture()` scoped to "module". +# Therefore, for those instances, we need to make sure that the +# application is removed from the agent. +@pytest.fixture(scope="module", autouse=True) +def remove_application_from_agent(): + yield + application = application_instance() + if application and application.name and (application.name in agent_instance()._applications): + del agent_instance()._applications[application.name] diff --git a/tests/framework_django/test_application.py b/tests/framework_django/test_application.py index 0aebaaadc..6734c3fe8 100644 --- a/tests/framework_django/test_application.py +++ b/tests/framework_django/test_application.py @@ -13,12 +13,13 @@ # limitations under the License. import os - import django from testing_support.fixtures import ( override_application_settings, override_generic_settings, override_ignore_status_codes, + collector_agent_registration_fixture, + collector_available_fixture, ) from testing_support.validators.validate_code_level_metrics import validate_code_level_metrics from testing_support.validators.validate_transaction_errors import validate_transaction_errors @@ -29,6 +30,19 @@ DJANGO_VERSION = tuple(map(int, django.get_version().split(".")[:2])) DJANGO_SETTINGS_MODULE = os.environ.get("DJANGO_SETTINGS_MODULE", None) +_default_settings = { + "package_reporting.enabled": False, # Turn off package reporting for testing as it causes slow downs. + "transaction_tracer.explain_threshold": 0.0, + "transaction_tracer.transaction_threshold": 0.0, + "transaction_tracer.stack_trace_threshold": 0.0, + "debug.log_data_collector_payloads": True, + "debug.record_transaction_failure": True, + "debug.log_autorum_middleware": True, +} + +collector_agent_registration = collector_agent_registration_fixture( + app_name="Python Agent Test (framework_django)", default_settings=_default_settings, scope="module" +) def target_application(): from _target_application import _target_application diff --git a/tests/framework_django/test_asgi_application.py b/tests/framework_django/test_asgi_application.py index 3ca49b691..16711b18b 100644 --- a/tests/framework_django/test_asgi_application.py +++ b/tests/framework_django/test_asgi_application.py @@ -16,7 +16,6 @@ import django import pytest -from testing_support.asgi_testing import AsgiTest from testing_support.fixtures import ( override_application_settings, override_generic_settings, @@ -25,15 +24,35 @@ from testing_support.validators.validate_code_level_metrics import validate_code_level_metrics from testing_support.validators.validate_transaction_errors import validate_transaction_errors from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics +from testing_support.validators.validate_transaction_count import validate_transaction_count from newrelic.common.encoding_utils import gzip_decompress from newrelic.core.config import global_settings +from testing_support.fixtures import collector_agent_registration_fixture, collector_available_fixture DJANGO_VERSION = tuple(map(int, django.get_version().split(".")[:2])) if DJANGO_VERSION[0] < 3: pytest.skip("support for asgi added in django 3", allow_module_level=True) +# Import this here so it is not run if Django is less than 3.0. +from testing_support.asgi_testing import AsgiTest # noqa: E402 + +_default_settings = { + "package_reporting.enabled": False, # Turn off package reporting for testing as it causes slow downs. + "transaction_tracer.explain_threshold": 0.0, + "transaction_tracer.transaction_threshold": 0.0, + "transaction_tracer.stack_trace_threshold": 0.0, + "debug.log_data_collector_payloads": True, + "debug.record_transaction_failure": True, + "debug.log_autorum_middleware": True, +} + +collector_agent_registration = collector_agent_registration_fixture( + app_name="Python Agent Test (framework_django)", default_settings=_default_settings, scope="module" +) + + scoped_metrics = [ ("Function/django.contrib.sessions.middleware:SessionMiddleware", 1), ("Function/django.middleware.common:CommonMiddleware", 1), @@ -180,6 +199,7 @@ def test_asgi_template_render(application): assert response.status == 200 +@validate_transaction_count(0) @override_generic_settings(global_settings(), {"enabled": False}) def test_asgi_nr_disabled(application): response = application.get("/") diff --git a/tests/framework_django/test_middleware_setting_toggle.py b/tests/framework_django/test_middleware_setting_toggle.py new file mode 100644 index 000000000..62baf7800 --- /dev/null +++ b/tests/framework_django/test_middleware_setting_toggle.py @@ -0,0 +1,98 @@ +# 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 os + +import django +import pytest +from testing_support.fixtures import django_collector_agent_registration_fixture, collector_available_fixture +from testing_support.validators.validate_code_level_metrics import validate_code_level_metrics +from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics + +DJANGO_VERSION = tuple(map(int, django.get_version().split(".")[:2])) + +if DJANGO_VERSION[0] < 3: + pytest.skip("support for asgi added in django 3", allow_module_level=True) + +# Import this here so it is not run if Django is less than 3.0. +from testing_support.asgi_testing import AsgiTest # noqa: E402 + +_default_settings = { + "package_reporting.enabled": False, # Turn off package reporting for testing as it causes slow downs. + "transaction_tracer.explain_threshold": 0.0, + "transaction_tracer.transaction_threshold": 0.0, + "transaction_tracer.stack_trace_threshold": 0.0, + "debug.log_data_collector_payloads": True, + "debug.record_transaction_failure": True, + "debug.log_autorum_middleware": True, +} + +@pytest.fixture(params=[False, True]) +def settings_and_metrics(request): + collect_middleware = request.param + middleware_scoped_metrics = [ + ("Function/django.contrib.sessions.middleware:SessionMiddleware", 1 if collect_middleware else None), + ("Function/django.middleware.common:CommonMiddleware", 1 if collect_middleware else None), + ("Function/django.middleware.csrf:CsrfViewMiddleware", 1 if collect_middleware else None), + ("Function/django.contrib.auth.middleware:AuthenticationMiddleware", 1 if collect_middleware else None), + ("Function/django.contrib.messages.middleware:MessageMiddleware", 1 if collect_middleware else None), + ("Function/django.middleware.gzip:GZipMiddleware", 1 if collect_middleware else None), + ("Function/middleware:ExceptionTo410Middleware", 1 if collect_middleware else None), + ("Function/django.urls.resolvers:URLResolver.resolve", "present"), + ] + _default_settings["instrumentation.django_middleware.enabled"] = collect_middleware + return _default_settings, middleware_scoped_metrics + + +@pytest.fixture +def settings_fixture(request, settings_and_metrics): + _default_settings, _ = settings_and_metrics + return _default_settings + + +@pytest.fixture +def middleware_scoped_metrics(request, settings_and_metrics): + _, middleware_scoped_metrics = settings_and_metrics + return middleware_scoped_metrics + [("Function/views:index", 1)] + + +@pytest.fixture +def middleware_rollup_metrics(request, settings_and_metrics): + _, middleware_scoped_metrics = settings_and_metrics + return middleware_scoped_metrics + [(f"Python/Framework/Django/{django.get_version()}", 1)] + + +@pytest.fixture +def application(): + from django.core.asgi import get_asgi_application + + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") + return AsgiTest(get_asgi_application()) + + +collector_agent_registration = django_collector_agent_registration_fixture( + app_name="Python Agent Test (framework_django)", scope="function" +) + + +def test_asgi_middleware_toggled_setting(application, middleware_scoped_metrics, middleware_rollup_metrics): + @validate_transaction_metrics( + "views:index", scoped_metrics=middleware_scoped_metrics, rollup_metrics=middleware_rollup_metrics + ) + @validate_code_level_metrics("views", "index") + def _test(): + response = application.get("/") + assert response.status == 200 + + _test() diff --git a/tests/framework_django/test_wildcard_filters.py b/tests/framework_django/test_wildcard_filters.py new file mode 100644 index 000000000..3962306db --- /dev/null +++ b/tests/framework_django/test_wildcard_filters.py @@ -0,0 +1,138 @@ +# 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 os + +import django +import pytest +from testing_support.fixtures import django_collector_agent_registration_fixture, collector_available_fixture +from testing_support.validators.validate_code_level_metrics import validate_code_level_metrics +from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics +from testing_support.validators.validate_transaction_count import validate_transaction_count + +DJANGO_VERSION = tuple(map(int, django.get_version().split(".")[:2])) + +if DJANGO_VERSION[0] < 3: + pytest.skip("support for asgi added in django 3", allow_module_level=True) + +# Import this here so it is not run if Django is less than 3.0. +from testing_support.asgi_testing import AsgiTest # noqa: E402 + +_default_settings = { + "package_reporting.enabled": False, # Turn off package reporting for testing as it causes slow downs. + "transaction_tracer.explain_threshold": 0.0, + "transaction_tracer.transaction_threshold": 0.0, + "transaction_tracer.stack_trace_threshold": 0.0, + "debug.log_data_collector_payloads": True, + "debug.record_transaction_failure": True, + "debug.log_autorum_middleware": True, + "instrumentation.django_middleware.enabled": True, +} + +wildcard_exclude_specific_include_settings = ( + ["django.middleware.*", "django.contrib.messages.middleware:MessageMiddleware"], + ["django.middleware.csrf:CsrfViewMiddleware"], +) + +wildcard_exclude_wildcard_include_settings = ( + ["django.middleware.c*"], + ["django.middleware.*", "django.middleware.co*", "django.contrib.messages.middleware:MessageMiddleware"], +) + +scoped_metrics_wildcard_exclude_specific_include = [ + ("Function/django.contrib.sessions.middleware:SessionMiddleware", 1), + ("Function/django.middleware.common:CommonMiddleware", None), # Wildcard exclude + ("Function/django.middleware.csrf:CsrfViewMiddleware", 1), # Specific include overrides wildcard exclude + ("Function/django.contrib.auth.middleware:AuthenticationMiddleware", 1), + ("Function/django.contrib.messages.middleware:MessageMiddleware", None), # Specific exclude + ("Function/django.middleware.gzip:GZipMiddleware", None), # Wildcard exclude + ("Function/middleware:ExceptionTo410Middleware", 1), + ("Function/django.urls.resolvers:URLResolver.resolve", "present"), +] + +scoped_metrics_wildcard_exclude_wildcard_include = [ + ("Function/django.contrib.sessions.middleware:SessionMiddleware", 1), + ("Function/django.middleware.common:CommonMiddleware", 1), # Specific include overrides wildcard exclude + ("Function/django.middleware.csrf:CsrfViewMiddleware", None), # Wildcard exclude + ("Function/django.contrib.auth.middleware:AuthenticationMiddleware", 1), + ("Function/django.contrib.messages.middleware:MessageMiddleware", 1), + ("Function/django.middleware.gzip:GZipMiddleware", 1), + ("Function/middleware:ExceptionTo410Middleware", 1), + ("Function/django.urls.resolvers:URLResolver.resolve", "present"), +] + + +@pytest.fixture(params=[ + (wildcard_exclude_specific_include_settings, scoped_metrics_wildcard_exclude_specific_include), + (wildcard_exclude_wildcard_include_settings, scoped_metrics_wildcard_exclude_wildcard_include), + ], + ids=[ + "wildcard_exclude_specific_include", + "wildcard_exclude_wildcard_include", + ] + ) +def settings_and_metrics(request): + exclude_include_override_settings, middleware_scoped_metrics = request.param + exclude_settings, include_settings = exclude_include_override_settings + + _default_settings["instrumentation.django_middleware.exclude"] = exclude_settings + _default_settings["instrumentation.django_middleware.include"] = include_settings + + return _default_settings, middleware_scoped_metrics + + +@pytest.fixture +def settings_fixture(request, settings_and_metrics): + _default_settings, _ = settings_and_metrics + return _default_settings + + +@pytest.fixture +def middleware_scoped_metrics(request, settings_and_metrics): + _, middleware_scoped_metrics = settings_and_metrics + return middleware_scoped_metrics + [("Function/views:index", 1)] + + +@pytest.fixture +def middleware_rollup_metrics(request, settings_and_metrics): + _, middleware_scoped_metrics = settings_and_metrics + return middleware_scoped_metrics + [(f"Python/Framework/Django/{django.get_version()}", 1)] + + +@pytest.fixture +def application(): + from django.core.asgi import get_asgi_application + + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") + return AsgiTest(get_asgi_application()) + + +collector_agent_registration = django_collector_agent_registration_fixture( + app_name="Python Agent Test (framework_django)", scope="function" +) + + +def test_wildcard_filters(application, middleware_scoped_metrics, middleware_rollup_metrics): + @validate_transaction_count(1) + @validate_transaction_metrics( + "views:index", + scoped_metrics=middleware_scoped_metrics, + rollup_metrics=middleware_rollup_metrics, + ) + @validate_code_level_metrics("views", "index") + def _test(): + response = application.get("/") + assert response.status == 200 + + _test() diff --git a/tests/testing_support/fixtures.py b/tests/testing_support/fixtures.py index ae4403a10..110e73a1d 100644 --- a/tests/testing_support/fixtures.py +++ b/tests/testing_support/fixtures.py @@ -22,6 +22,7 @@ import time from pathlib import Path from queue import Queue +from pathlib import Path import pytest @@ -38,7 +39,7 @@ wrap_function_wrapper, ) from newrelic.config import initialize -from newrelic.core.agent import shutdown_agent +from newrelic.core.agent import shutdown_agent, agent_instance from newrelic.core.attribute import create_attributes from newrelic.core.attribute_filter import DST_ERROR_COLLECTOR, DST_TRANSACTION_TRACER, AttributeFilter from newrelic.core.config import apply_config_setting, flatten_settings, global_settings @@ -182,12 +183,12 @@ def _bind_params(name, value, *args, **kwargs): def collector_agent_registration_fixture( - app_name=None, default_settings=None, linked_applications=None, should_initialize_agent=True + app_name=None, default_settings=None, linked_applications=None, should_initialize_agent=True, scope="session" ): default_settings = default_settings or {} linked_applications = linked_applications or [] - @pytest.fixture(scope="session") + @pytest.fixture(scope=scope) def _collector_agent_registration_fixture(request): if should_initialize_agent: initialize_agent(app_name=app_name, default_settings=default_settings) @@ -255,6 +256,82 @@ def _collector_agent_registration_fixture(request): return _collector_agent_registration_fixture +def django_collector_agent_registration_fixture( + app_name=None, linked_applications=None, should_initialize_agent=True, scope="session" +): + # To use this, make sure a fixture called settings_fixture + # is defined within the scope of the test + linked_applications = linked_applications or [] + + @pytest.fixture(scope=scope) + def _collector_agent_registration_fixture(request, settings_fixture): + if should_initialize_agent: + initialize_agent(app_name=app_name, default_settings=settings_fixture) + + settings = global_settings() + + # Determine if should be using an internal fake local + # collector for the test. + + use_fake_collector = _environ_as_bool("NEW_RELIC_FAKE_COLLECTOR", False) + use_developer_mode = _environ_as_bool("NEW_RELIC_DEVELOPER_MODE", use_fake_collector) + + # Catch exceptions in the harvest thread and reraise them in the main + # thread. This way the tests will reveal any unhandled exceptions in + # either of the two agent threads. + + capture_harvest_errors() + + # Associate linked applications. + + application = application_instance() + + for name in linked_applications: + application.link_to_application(name) + + # Force registration of the application. + + application = register_application() + + # Attempt to record deployment marker for test. It's ok + # if the deployment marker does not record successfully. + + api_host = settings.host + + if api_host is None: + api_host = "api.newrelic.com" + elif api_host == "staging-collector.newrelic.com": + api_host = "staging-api.newrelic.com" + + if not use_fake_collector and not use_developer_mode: + description = Path(os.path.normpath(sys.prefix)).name + try: + _logger.debug("Record deployment marker host: %s", api_host) + record_deploy( + host=api_host, + api_key=settings.api_key, + app_name=settings.app_name, + description=description, + port=settings.port or 443, + proxy_scheme=settings.proxy_scheme, + proxy_host=settings.proxy_host, + proxy_user=settings.proxy_user, + proxy_pass=settings.proxy_pass, + timeout=settings.agent_limits.data_collector_timeout, + ca_bundle_path=settings.ca_bundle_path, + disable_certificate_validation=settings.debug.disable_certificate_validation, + ) + except Exception: + _logger.exception("Unable to record deployment marker.") + + yield application + + shutdown_agent() + del agent_instance()._applications[application.name] + + return _collector_agent_registration_fixture + + @pytest.fixture def collector_available_fixture(request, collector_agent_registration): application = application_instance()