From a22282793e9ec0b656400e07b5c898366d0a3628 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Mon, 7 Jan 2019 13:48:50 +0100 Subject: [PATCH] feat: Django template source --- sentry_sdk/integrations/django/__init__.py | 28 +++++ sentry_sdk/integrations/django/templates.py | 102 ++++++++++++++++++ sentry_sdk/utils.py | 22 ++-- tests/integrations/django/myapp/settings.py | 5 +- .../django/myapp/templates/error.html | 20 ++++ tests/integrations/django/myapp/urls.py | 1 + tests/integrations/django/myapp/views.py | 5 + tests/integrations/django/test_basic.py | 26 +++++ 8 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 sentry_sdk/integrations/django/templates.py create mode 100644 tests/integrations/django/myapp/templates/error.html diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 75aef0b8ce..81e3d54a00 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -29,18 +29,21 @@ def sql_to_string(sql): from sentry_sdk import Hub from sentry_sdk.hub import _should_send_default_pii +from sentry_sdk.scope import add_global_event_processor from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, safe_repr, format_and_strip, transaction_from_function, + walk_exception_chain, ) from sentry_sdk.integrations import Integration from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.integrations._wsgi_common import RequestExtractor from sentry_sdk.integrations.django.transactions import LEGACY_RESOLVER +from sentry_sdk.integrations.django.templates import get_template_frame_from_exception if DJANGO_VERSION < (1, 10): @@ -112,6 +115,31 @@ def sentry_patched_get_response(self, request): signals.got_request_exception.connect(_got_request_exception) + @add_global_event_processor + def process_django_templates(event, hint): + if hint.get("exc_info", None) is None: + return event + + if "exception" not in event: + return event + + exception = event["exception"] + + if "values" not in exception: + return event + + for exception, (_, exc_value, _) in zip( + exception["values"], walk_exception_chain(hint["exc_info"]) + ): + frame = get_template_frame_from_exception(exc_value) + if frame is not None: + frames = exception.setdefault("stacktrace", {}).setdefault( + "frames", [] + ) + frames.append(frame) + + return event + def _make_event_processor(weak_request, integration): def event_processor(event, hint): diff --git a/sentry_sdk/integrations/django/templates.py b/sentry_sdk/integrations/django/templates.py new file mode 100644 index 0000000000..f058861bcd --- /dev/null +++ b/sentry_sdk/integrations/django/templates.py @@ -0,0 +1,102 @@ +from django.template import TemplateSyntaxError + +try: + # support Django 1.9 + from django.template.base import Origin +except ImportError: + # backward compatibility + from django.template.loader import LoaderOrigin as Origin + + +def get_template_frame_from_exception(exc_value): + # As of Django 1.9 or so the new template debug thing showed up. + if hasattr(exc_value, "template_debug"): + return _get_template_frame_from_debug(exc_value.template_debug) + + # As of r16833 (Django) all exceptions may contain a + # ``django_template_source`` attribute (rather than the legacy + # ``TemplateSyntaxError.source`` check) + if hasattr(exc_value, "django_template_source"): + return _get_template_frame_from_source(exc_value.django_template_source) + + if isinstance(exc_value, TemplateSyntaxError) and hasattr(exc_value, "source"): + source = exc_value.source + if isinstance(source, (tuple, list)) and isinstance(source[0], Origin): + return _get_template_frame_from_source(source) + + +def _get_template_frame_from_debug(debug): + if debug is None: + return None + + lineno = debug["line"] + filename = debug["name"] + if filename is None: + filename = "" + + pre_context = [] + post_context = [] + context_line = None + + for i, line in debug["source_lines"]: + if i < lineno: + pre_context.append(line) + elif i > lineno: + post_context.append(line) + else: + context_line = line + + return { + "filename": filename, + "lineno": lineno, + "pre_context": pre_context[-5:], + "post_context": post_context[:5], + "context_line": context_line, + } + + +def _linebreak_iter(template_source): + yield 0 + p = template_source.find("\n") + while p >= 0: + yield p + 1 + p = template_source.find("\n", p + 1) + + +def _get_template_frame_from_source(source): + if not source: + return None + + origin, (start, end) = source + filename = getattr(origin, "loadname", None) + if filename is None: + filename = "" + template_source = origin.reload() + lineno = None + upto = 0 + pre_context = [] + post_context = [] + context_line = None + + for num, next in enumerate(_linebreak_iter(template_source)): + line = template_source[upto:next] + if start >= upto and end <= next: + lineno = num + context_line = line + elif lineno is None: + pre_context.append(line) + else: + post_context.append(line) + + upto = next + + if context_line is None or lineno is None: + return None + + return { + "filename": filename, + "lineno": lineno, + "pre_context": pre_context[-5:], + "post_context": post_context[:5], + "context_line": context_line, + } diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index bafb3cae0d..5198572401 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -427,21 +427,29 @@ def single_exception_from_error_tuple( } -def exceptions_from_error_tuple(exc_info, client_options=None, mechanism=None): +def walk_exception_chain(exc_info): exc_type, exc_value, tb = exc_info - rv = [] + while exc_type is not None: - rv.append( - single_exception_from_error_tuple( - exc_type, exc_value, tb, client_options, mechanism - ) - ) + yield exc_type, exc_value, tb + cause = getattr(exc_value, "__cause__", None) if cause is None: break exc_type = type(cause) exc_value = cause tb = getattr(cause, "__traceback__", None) + + +def exceptions_from_error_tuple(exc_info, client_options=None, mechanism=None): + exc_type, exc_value, tb = exc_info + rv = [] + for exc_type, exc_value, tb in walk_exception_chain(exc_info): + rv.append( + single_exception_from_error_tuple( + exc_type, exc_value, tb, client_options, mechanism + ) + ) return rv diff --git a/tests/integrations/django/myapp/settings.py b/tests/integrations/django/myapp/settings.py index b93cf4b185..2f175291f2 100644 --- a/tests/integrations/django/myapp/settings.py +++ b/tests/integrations/django/myapp/settings.py @@ -75,12 +75,13 @@ def process_response(self, request, response): "DIRS": [], "APP_DIRS": True, "OPTIONS": { + "debug": True, "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", - ] + ], }, } ] @@ -120,6 +121,8 @@ def process_response(self, request, response): USE_TZ = True +TEMPLATE_DEBUG = True + # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.0/howto/static-files/ diff --git a/tests/integrations/django/myapp/templates/error.html b/tests/integrations/django/myapp/templates/error.html new file mode 100644 index 0000000000..9f601208a9 --- /dev/null +++ b/tests/integrations/django/myapp/templates/error.html @@ -0,0 +1,20 @@ +1 +2 +3 +4 +5 +6 +7 +8 +9 +{% invalid template tag %} +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 diff --git a/tests/integrations/django/myapp/urls.py b/tests/integrations/django/myapp/urls.py index 1014c36073..20c01e9dd6 100644 --- a/tests/integrations/django/myapp/urls.py +++ b/tests/integrations/django/myapp/urls.py @@ -29,6 +29,7 @@ path("mylogin", views.mylogin, name="mylogin"), path("classbased", views.ClassBasedView.as_view(), name="classbased"), path("post-echo", views.post_echo, name="post_echo"), + path("template-exc", views.template_exc, name="template_exc"), ] handler500 = views.handler500 diff --git a/tests/integrations/django/myapp/views.py b/tests/integrations/django/myapp/views.py index 1d4c468d44..a5c676dde2 100644 --- a/tests/integrations/django/myapp/views.py +++ b/tests/integrations/django/myapp/views.py @@ -1,6 +1,7 @@ from django.contrib.auth import login from django.contrib.auth.models import User from django.http import HttpResponse, HttpResponseServerError, HttpResponseNotFound +from django.shortcuts import render_to_response from django.views.generic import ListView import sentry_sdk @@ -42,3 +43,7 @@ def post_echo(request): def handler404(*args, **kwargs): sentry_sdk.capture_message("not found", level="error") return HttpResponseNotFound("404") + + +def template_exc(*args, **kwargs): + return render_to_response("error.html") diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py index cebe58d648..498775af13 100644 --- a/tests/integrations/django/test_basic.py +++ b/tests/integrations/django/test_basic.py @@ -288,3 +288,29 @@ def test_request_body(sentry_init, client, capture_events): assert event["message"] == "hi" assert event["request"]["data"] == {"hey": 42} assert "" not in event + + +def test_template_exception(sentry_init, client, capture_events): + sentry_init(integrations=[DjangoIntegration()]) + events = capture_events() + + content, status, headers = client.get(reverse("template_exc")) + assert status.lower() == "500 internal server error" + + event, = events + exception, = event["exception"]["values"] + + frames = [ + f + for f in exception["stacktrace"]["frames"] + if not f["filename"].startswith("django/") + ] + view_frame, template_frame = frames[-2:] + + assert template_frame["context_line"] == "{% invalid template tag %}\n" + assert template_frame["pre_context"] == ["5\n", "6\n", "7\n", "8\n", "9\n"] + + assert template_frame["post_context"] == ["11\n", "12\n", "13\n", "14\n", "15\n"] + assert template_frame["lineno"] == 10 + assert template_frame["in_app"] + assert template_frame["filename"].endswith("error.html")