Repository: getsentry/sentry-python

Issue:
Support Django Templates
crashes in django templates used to have the template interface attached. Change this in the new SDK to attach a fake frame


Discussion:

Resolving patch:
diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py
--- 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
--- /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 = "<django template>"
+
+    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 = "<django template>"
+    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
--- 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
 
 


Test patch:
diff --git a/tests/integrations/django/myapp/settings.py b/tests/integrations/django/myapp/settings.py
--- 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
--- /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
--- 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
--- 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
--- 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")