Skip to content

Commit b6631c8

Browse files
lrafeeihmstepanekmergify[bot]
authored
Django middleware filtering settings (#1444)
* Reduce number of spans in django framework (#779) * Do not wrap useless middlewares * Fixup: use frozenset * Add config settings * Add middleware enable/disable options * Add testing * Testing exclude/include settings * Add optional fixture scope argument * Rewrite tests to use fixtures * Add new fixture * Fix tests * Fix ruff errors * MegaLinter Fixes * Add config file tests * MegaLinter fixes * Reviewer changes * MegaLinter fixes * Add InstrumentationMiddlewareSettings * More exclude/include filter tests * Megalinter fixes * Tests to increase coverage * Megalinter fixes * More coverage tests * ANOTHER TEST: --------- Co-authored-by: Hannah Stepanek <[email protected]> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent d1bcc83 commit b6631c8

File tree

10 files changed

+903
-94
lines changed

10 files changed

+903
-94
lines changed

newrelic/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@ def _map_inc_excl_attributes(s):
215215
return newrelic.core.config._parse_attributes(s)
216216

217217

218+
def _map_inc_excl_middleware(s):
219+
return newrelic.core.config._parse_attributes(s, middleware=True)
220+
221+
218222
def _map_case_insensitive_excl_labels(s):
219223
return [v.lower() for v in newrelic.core.config._parse_attributes(s)]
220224

@@ -510,6 +514,9 @@ def _process_configuration(section):
510514
section, "instrumentation.kombu.ignored_exchanges", "get", newrelic.core.config.parse_space_separated_into_list
511515
)
512516
_process_setting(section, "instrumentation.kombu.consumer.enabled", "getboolean", None)
517+
_process_setting(section, "instrumentation.middleware.django.enabled", "getboolean", None)
518+
_process_setting(section, "instrumentation.middleware.django.exclude", "get", _map_inc_excl_middleware)
519+
_process_setting(section, "instrumentation.middleware.django.include", "get", _map_inc_excl_middleware)
513520

514521

515522
# Loading of configuration from specified file and for specified

newrelic/core/config.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,14 @@ class SpanEventAttributesSettings(Settings):
320320
pass
321321

322322

323+
class InstrumentationMiddlewareSettings(Settings):
324+
pass
325+
326+
327+
class InstrumentationDjangoMiddlewareSettings(Settings):
328+
pass
329+
330+
323331
class DistributedTracingSettings(Settings):
324332
pass
325333

@@ -507,6 +515,8 @@ class EventHarvestConfigHarvestLimitSettings(Settings):
507515
_settings.instrumentation.kombu = InstrumentationKombuSettings()
508516
_settings.instrumentation.kombu.ignored_exchanges = InstrumentationKombuIgnoredExchangesSettings()
509517
_settings.instrumentation.kombu.consumer = InstrumentationKombuConsumerSettings()
518+
_settings.instrumentation.middleware = InstrumentationMiddlewareSettings()
519+
_settings.instrumentation.middleware.django = InstrumentationDjangoMiddlewareSettings()
510520
_settings.message_tracer = MessageTracerSettings()
511521
_settings.process_host = ProcessHostSettings()
512522
_settings.rum = RumSettings()
@@ -634,11 +644,15 @@ def _parse_status_codes(value, target):
634644
return target
635645

636646

637-
def _parse_attributes(s):
647+
# Called from newrelic.config.py to parse
648+
# attributes and django middleware lists
649+
def _parse_attributes(s, middleware=False):
638650
valid = []
639651
for item in s.split():
640652
if "*" not in item[:-1] and len(item.encode("utf-8")) < 256:
641653
valid.append(item)
654+
elif middleware:
655+
_logger.warning("Improperly formatted middleware: %r", item)
642656
else:
643657
_logger.warning("Improperly formatted attribute: %r", item)
644658
return valid
@@ -904,6 +918,12 @@ def default_otlp_host(host):
904918
"NEW_RELIC_INSTRUMENTATION_KOMBU_CONSUMER_ENABLED", default=False
905919
)
906920

921+
_settings.instrumentation.middleware.django.enabled = _environ_as_bool(
922+
"NEW_RELIC_INSTRUMENTATION_DJANGO_MIDDLEWARE_ENABLED", default=True
923+
)
924+
_settings.instrumentation.middleware.django.exclude = []
925+
_settings.instrumentation.middleware.django.include = []
926+
907927
_settings.event_harvest_config.harvest_limits.analytic_event_data = _environ_as_int(
908928
"NEW_RELIC_ANALYTICS_EVENTS_MAX_SAMPLES_STORED", DEFAULT_RESERVOIR_SIZE
909929
)

newrelic/hooks/framework_django.py

Lines changed: 125 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import threading
1919
import warnings
2020

21-
from newrelic.api.application import register_application
21+
from newrelic.api.application import application_settings, register_application
2222
from newrelic.api.background_task import BackgroundTaskWrapper
2323
from newrelic.api.error_trace import wrap_error_trace
2424
from newrelic.api.function_trace import FunctionTrace, FunctionTraceWrapper, wrap_function_trace
@@ -238,77 +238,12 @@ def wrapper(wrapped, instance, args, kwargs):
238238
return FunctionWrapper(wrapped, wrapper)
239239

240240
for wrapped in middleware:
241-
yield wrapper(wrapped)
242-
243-
244-
# Because this is not being used in any version of Django that is
245-
# within New Relic's support window, no tests will be added
246-
# for this. However, value exists to keeping backwards compatible
247-
# functionality, so instead of removing this instrumentation, this
248-
# will be excluded from the coverage analysis.
249-
def wrap_view_middleware(middleware): # pragma: no cover
250-
# This is no longer being used. The changes to strip the
251-
# wrapper from the view handler when passed into the function
252-
# urlresolvers.reverse() solves most of the problems. To back
253-
# that up, the object wrapper now proxies various special
254-
# methods so that comparisons like '==' will work. The object
255-
# wrapper can even be used as a standin for the wrapped object
256-
# when used as a key in a dictionary and will correctly match
257-
# the original wrapped object.
258-
259-
# Wrapper to be applied to view middleware. Records the time
260-
# spent in the middleware as separate function node and also
261-
# attempts to name the web transaction after the name of the
262-
# middleware with success being determined by the priority.
263-
# This wrapper is special in that it must strip the wrapper
264-
# from the view handler when being passed to the view
265-
# middleware to avoid issues where middleware wants to do
266-
# comparisons between the passed middleware and some other
267-
# value. It is believed that the view handler should never
268-
# actually be called from the view middleware so not an
269-
# issue that no longer wrapped at this point.
241+
do_not_wrap = is_denied_middleware(callable_name(wrapped))
270242

271-
def wrapper(wrapped):
272-
# The middleware if a class method would already be
273-
# bound at this point, so is safe to determine the name
274-
# when it is being wrapped rather than on each
275-
# invocation.
276-
277-
name = callable_name(wrapped)
278-
279-
def wrapper(wrapped, instance, args, kwargs):
280-
transaction = current_transaction()
281-
282-
def _wrapped(request, view_func, view_args, view_kwargs):
283-
# This strips the view handler wrapper before call.
284-
285-
if hasattr(view_func, "_nr_last_object"):
286-
view_func = view_func._nr_last_object
287-
288-
return wrapped(request, view_func, view_args, view_kwargs)
289-
290-
if transaction is None:
291-
return _wrapped(*args, **kwargs)
292-
293-
before = (transaction.name, transaction.group)
294-
295-
with FunctionTrace(name=name, source=wrapped):
296-
try:
297-
return _wrapped(*args, **kwargs)
298-
299-
finally:
300-
# We want to name the transaction after this
301-
# middleware but only if the transaction wasn't
302-
# named from within the middleware itself explicitly.
303-
304-
after = (transaction.name, transaction.group)
305-
if before == after:
306-
transaction.set_transaction_name(name, priority=2)
307-
308-
return FunctionWrapper(wrapped, wrapper)
309-
310-
for wrapped in middleware:
311-
yield wrapper(wrapped)
243+
if do_not_wrap:
244+
yield wrapped
245+
else:
246+
yield wrapper(wrapped)
312247

313248

314249
def wrap_trailing_middleware(middleware):
@@ -323,7 +258,13 @@ def wrap_trailing_middleware(middleware):
323258
# invocation.
324259

325260
for wrapped in middleware:
326-
yield FunctionTraceWrapper(wrapped, name=callable_name(wrapped))
261+
name = callable_name(wrapped)
262+
do_not_wrap = is_denied_middleware(name)
263+
264+
if do_not_wrap:
265+
yield wrapped
266+
else:
267+
yield FunctionTraceWrapper(wrapped, name=name)
327268

328269

329270
def insert_and_wrap_middleware(handler, *args, **kwargs):
@@ -1149,17 +1090,126 @@ def _wrapper(wrapped, instance, args, kwargs):
11491090
return _wrapper(middleware)
11501091

11511092

1093+
# For faster logic, could we cache the callable_names to
1094+
# avoid this logic for multiple calls to the same middleware?
1095+
# Also, is that even a scenario we need to worry about?
1096+
def is_denied_middleware(callable_name):
1097+
settings = application_settings() or global_settings()
1098+
1099+
# Return True (skip wrapping) if:
1100+
# 1. middleware wrapping is disabled or
1101+
# 2. the callable name is in the exclude list
1102+
if not settings.instrumentation.middleware.django.enabled or (
1103+
callable_name in settings.instrumentation.middleware.django.exclude
1104+
):
1105+
return True
1106+
1107+
# Return False (middleware will be wrapped) if:
1108+
# 1. If the callable name is in the include list.
1109+
# If we have made it to this point in the logic, that means
1110+
# that the callable name is not explicitly in the exclude
1111+
# list and, whether it is in the exclude list as a wildcard
1112+
# or not, the list of middleware to include explicitly takes
1113+
# precedence over the exclude list's wildcards.
1114+
# 2. enabled=True and len(exclude)==0
1115+
# This scenario is redundant logic, but we utilize it to
1116+
# speed up the logic for the common case where
1117+
# the user has not specified any include or exclude lists.
1118+
if (callable_name in settings.instrumentation.middleware.django.include) or (
1119+
settings.instrumentation.middleware.django.enabled
1120+
and len(settings.instrumentation.middleware.django.exclude) == 0
1121+
):
1122+
return False
1123+
1124+
# =========================================================
1125+
# Wildcard logic for include and exclude lists:
1126+
# =========================================================
1127+
# Returns False if an entry in the include wildcard is more
1128+
# specific than exclude wildcard. Otherwise, it will iterate
1129+
# through the entire include list and return True if no more
1130+
# specific include (than exclude) is found.
1131+
def include_logic(callable_name, exclude_middleware_name):
1132+
"""
1133+
We iterate through the entire include and exclude lists to
1134+
ensure that the user has not included overlapping wildcards
1135+
that would potentially be less specific than the exclude in
1136+
one case and more specific than the exclude in another case.
1137+
1138+
e.g.:
1139+
exclude = middleware.*, middleware.parameters.bar*
1140+
include = middleware.parameters.*
1141+
1142+
Where `middleware.*` is less specific than `middleware.parameters.*`
1143+
but `middleware.parameters.bar*` is more specific than `middleware.parameters.*`
1144+
1145+
In this case, we want to make sure to exclude any middleware that
1146+
matches the `middleware.parameters.bar*` pattern, but include any
1147+
other middleware that matches the `middleware.parameters.*` pattern.
1148+
"""
1149+
for include_middleware in settings.instrumentation.middleware.django.include:
1150+
if include_middleware.endswith("*"):
1151+
include_middleware_name = include_middleware.rstrip("*")
1152+
if callable_name.startswith(include_middleware_name):
1153+
# Count number of dots in the include and exclude
1154+
# middleware names to determine specificity.
1155+
exclude_dots = exclude_middleware_name.count(".")
1156+
include_dots = include_middleware_name.count(".")
1157+
if include_dots > exclude_dots:
1158+
# Include this middleware--include is more specific and takes precedence
1159+
return False
1160+
elif include_dots == exclude_dots:
1161+
# Count number of characters after the last dot
1162+
exclude_post_dot_char_count = len(exclude_middleware_name) - exclude_middleware_name.rfind(".")
1163+
include_post_dot_char_count = len(include_middleware_name) - include_middleware_name.rfind(".")
1164+
if include_post_dot_char_count > exclude_post_dot_char_count:
1165+
# Include this middleware--include is more specific and takes precedence
1166+
return False
1167+
else:
1168+
# Exclude wildcard has the same or greater specificity than the include wildcard
1169+
# Expect to exclude this middleware--so far, exclude is more specific and takes precedence
1170+
continue
1171+
else:
1172+
# Expect to exclude this middleware--so far, exclude is more specific and takes precedence
1173+
pass
1174+
1175+
# if we have made it to this point, there is no include middleware that is
1176+
# more specific than the exclude middleware, so we can safely return True
1177+
return True
1178+
1179+
# Check if the callable name matches any of the excluded middleware patterns.
1180+
# If middleware name ends with '*', it is a wildcard
1181+
deny = False
1182+
for exclude_middleware in settings.instrumentation.middleware.django.exclude:
1183+
if exclude_middleware.endswith("*"):
1184+
name = exclude_middleware.rstrip("*")
1185+
if callable_name.startswith(name):
1186+
include = include_logic(callable_name, name)
1187+
if not include:
1188+
return False
1189+
else:
1190+
deny |= include
1191+
1192+
# If we have made it to this point, there are contents within
1193+
# the exclude list and those contents act as a no-op with
1194+
# respect to the specific middleware being evaluated
1195+
return deny
1196+
1197+
11521198
def _nr_wrapper_convert_exception_to_response_(wrapped, instance, args, kwargs):
11531199
def _bind_params(original_middleware, *args, **kwargs):
11541200
return original_middleware
11551201

11561202
original_middleware = _bind_params(*args, **kwargs)
11571203
converted_middleware = wrapped(*args, **kwargs)
11581204
name = callable_name(original_middleware)
1205+
do_not_wrap = is_denied_middleware(name)
11591206

1160-
if is_coroutine_function(converted_middleware) or is_asyncio_coroutine(converted_middleware):
1161-
return _nr_wrap_converted_middleware_async_(converted_middleware, name)
1162-
return _nr_wrap_converted_middleware_(converted_middleware, name)
1207+
if do_not_wrap:
1208+
return converted_middleware
1209+
else:
1210+
if is_coroutine_function(converted_middleware) or is_asyncio_coroutine(converted_middleware):
1211+
return _nr_wrap_converted_middleware_async_(converted_middleware, name)
1212+
return _nr_wrap_converted_middleware_(converted_middleware, name)
11631213

11641214

11651215
def instrument_django_core_handlers_exception(module):

tests/framework_django/conftest.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,22 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import pytest
1516

16-
from testing_support.fixtures import collector_agent_registration_fixture, collector_available_fixture
17+
from newrelic.api.application import application_instance
18+
from newrelic.core.agent import agent_instance
1719

18-
_default_settings = {
19-
"package_reporting.enabled": False, # Turn off package reporting for testing as it causes slow downs.
20-
"transaction_tracer.explain_threshold": 0.0,
21-
"transaction_tracer.transaction_threshold": 0.0,
22-
"transaction_tracer.stack_trace_threshold": 0.0,
23-
"debug.log_data_collector_payloads": True,
24-
"debug.record_transaction_failure": True,
25-
"debug.log_autorum_middleware": True,
26-
"feature_flag": {"django.instrumentation.inclusion-tags.r1"},
27-
}
2820

29-
collector_agent_registration = collector_agent_registration_fixture(
30-
app_name="Python Agent Test (framework_django)", default_settings=_default_settings
31-
)
21+
# Even though `django_collector_agent_registration_fixture()` also
22+
# has the application deletion during the breakdown of the fixture,
23+
# and `django_collector_agent_registration_fixture()` is scoped to
24+
# "function", not all modules are using this. Some are using
25+
# `collector_agent_registration_fixture()` scoped to "module".
26+
# Therefore, for those instances, we need to make sure that the
27+
# application is removed from the agent.
28+
@pytest.fixture(scope="module", autouse=True)
29+
def remove_application_from_agent():
30+
yield
31+
application = application_instance()
32+
if application and application.name and (application.name in agent_instance()._applications):
33+
del agent_instance()._applications[application.name]

tests/framework_django/test_application.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
import django
1818
from testing_support.fixtures import (
19+
collector_agent_registration_fixture,
20+
collector_available_fixture,
1921
override_application_settings,
2022
override_generic_settings,
2123
override_ignore_status_codes,
@@ -29,6 +31,20 @@
2931
DJANGO_VERSION = tuple(map(int, django.get_version().split(".")[:2]))
3032
DJANGO_SETTINGS_MODULE = os.environ.get("DJANGO_SETTINGS_MODULE", None)
3133

34+
_default_settings = {
35+
"package_reporting.enabled": False, # Turn off package reporting for testing as it causes slow downs.
36+
"transaction_tracer.explain_threshold": 0.0,
37+
"transaction_tracer.transaction_threshold": 0.0,
38+
"transaction_tracer.stack_trace_threshold": 0.0,
39+
"debug.log_data_collector_payloads": True,
40+
"debug.record_transaction_failure": True,
41+
"debug.log_autorum_middleware": True,
42+
}
43+
44+
collector_agent_registration = collector_agent_registration_fixture(
45+
app_name="Python Agent Test (framework_django)", default_settings=_default_settings, scope="module"
46+
)
47+
3248

3349
def target_application():
3450
from _target_application import _target_application

0 commit comments

Comments
 (0)