From 0fb81b29aebb12b6217101541a8771dd1849c597 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Fri, 12 Sep 2025 15:23:19 +0200 Subject: [PATCH] markdown_stacktrace util: display an event's stacktrace in markdown --- events/markdown_stacktrace.py | 178 ++++++++++++++++++++++++++++++++++ issues/tests.py | 88 +++++++++++++++++ theme/tests.py | 2 +- 3 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 events/markdown_stacktrace.py diff --git a/events/markdown_stacktrace.py b/events/markdown_stacktrace.py new file mode 100644 index 0000000..d3ef146 --- /dev/null +++ b/events/markdown_stacktrace.py @@ -0,0 +1,178 @@ +# This module is almost entirely written by a chatbot, with heavy guidance in terms of desired outcome, but very little +# code review. It's smoke-tested against all sample events and char-for-char tested for a single representative event. +# +# Large parts mirror (have stolen from) existing stacktrace-rendering logic from our views/templates, trimmed down for a +# Markdown/LLM audience. +# +# Purpose: expose event stacktraces (frames, source, locals) as clean, low-maintenance text for humans and machine +# tools. As in the UI: focus on the stacktrace rather than the event metadata. +# +# The provided markdown is not a stable interface; it's intended to be useful but not something you'd parse +# programmatically (just use the event data instead). + + +from events.utils import apply_sourcemaps + + +def _code_segments(frame): + pre = frame.get("pre_context") or [] + ctx = frame.get("context_line") + post = frame.get("post_context") or [] + + pre = [("" if l is None else str(l)) for l in pre] + post = [("" if l is None else str(l)) for l in post] + if ctx is not None: + ctx = str(ctx) + + return pre, ctx, post + + +def _code_lines(frame): + pre, ctx, post = _code_segments(frame) + lines = [] + lines.extend(pre) + if ctx is not None: + lines.append(ctx) + lines.extend(post) + return lines + + +def _iter_exceptions(parsed): + exc = parsed.get("exception") + if not exc: + return [] + if isinstance(exc, dict): + return list(exc.get("values") or []) + if isinstance(exc, (list, tuple)): + return list(exc) + return [] + + +def _frames_for_exception(exc): + st = exc.get("stacktrace") or {} + return list(st.get("frames") or []) + + +def _header_lines(event, exc): + etype = exc.get("type") or "Exception" + val = exc.get("value") or "" + # Two-line title; no platform/event_id/timestamp clutter. + return [f"# {etype}", val] + + +def _format_frame_header(frame): + fn = frame.get("filename") or frame.get("abs_path") or "" + func = frame.get("function") or "" + lineno = frame.get("lineno") + in_app = frame.get("in_app") is True + scope = "in-app" if in_app else "external" + + header = f"### {fn}" + if lineno is not None: + header += f":{lineno}" + if func: + header += f" in `{func}`" + header += f" [{scope}]" + + debug_id = frame.get("debug_id") + if debug_id and not frame.get("mapped"): + header += f" (no sourcemap for debug_id {debug_id})" + return [header] + + +def _format_code_gutter(frame): + pre, ctx, post = _code_segments(frame) + if not pre and ctx is None and not post: + return [] + + lineno = frame.get("lineno") + if lineno is not None: + start = max(1, int(lineno) - len(pre)) + else: + start = 1 + + lines = list(pre) + ctx_index = None + if ctx is not None: + ctx_index = len(lines) + lines.append(ctx) + lines.extend(post) + + last_no = start + len(lines) - 1 + width = max(2, len(str(last_no))) + + out = [] + for i, text in enumerate(lines): + n = start + i + if ctx_index is not None and i == ctx_index: + out.append(f"▶ {str(n).rjust(width)} | {text}") + else: + out.append(f" {str(n).rjust(width)} | {text}") + return out + + +def _format_locals(frame): + vars_ = frame.get("vars") or {} + if not vars_: + return [] + lines = ["", "#### Locals", ""] + for k, v in vars_.items(): + lines.append(f"* `{k}` = `{v}`") + return lines + + +def _select_frames(frames, in_app_only): + if not in_app_only: + return frames + filtered = [f for f in frames if f.get("in_app") is True] + return filtered if filtered else frames + + +def render_stacktrace_md(event, frames="in_app", exceptions="last", include_locals=True): + parsed = event.get_parsed_data() + try: + apply_sourcemaps(parsed) + except Exception: + pass + + excs = _iter_exceptions(parsed) + if not excs: + return "_No stacktrace available._" + + stack_of_plates = getattr(event, "platform", None) != "python" + if stack_of_plates: + excs = list(reversed(excs)) + if exceptions == "last": + excs = excs[-1:] + + lines = [] + for i, exc in enumerate(excs): + if i > 0: + lines += ["", "**During handling of the above exception, another exception occurred:**", ""] + lines += _header_lines(event, exc) + + frames_list = _frames_for_exception(exc) or [] + if stack_of_plates and frames_list: + frames_list = list(reversed(frames_list)) + + in_app_only = frames == "in_app" + frames_list = _select_frames(frames_list, in_app_only) + + for frame in frames_list: + # spacer above every frame header + lines.append("") + lines += _format_frame_header(frame) + + code_listing = _format_code_gutter(frame) + if code_listing: + lines += code_listing + else: + # brief mention when no source context is available + lines.append("_no source context available_") + + if include_locals: + loc_lines = _format_locals(frame) + if loc_lines: + lines += loc_lines + + return "\n".join(lines).strip() diff --git a/issues/tests.py b/issues/tests.py index 52c905f..e9211f5 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -27,6 +27,7 @@ from ingest.views import BaseIngestAPIView from issues.factories import get_or_create_issue from tags.models import store_tags from tags.tasks import vacuum_tagvalues +from events.markdown_stacktrace import render_stacktrace_md from .models import Issue, IssueStateManager, TurningPoint, TurningPointKind from .regressions import is_regression, is_regression_2, issue_is_regression @@ -446,6 +447,7 @@ class IntegrationTest(TransactionTestCase): def setUp(self): super().setUp() self.verbosity = self.get_verbosity() + self.maxDiff = None # show full diff on assertEqual failures def get_verbosity(self): # https://stackoverflow.com/a/27457315/339144 @@ -527,6 +529,8 @@ class IntegrationTest(TransactionTestCase): filename, response.content if response.status_code != 302 else response.url)) for event in Event.objects.all(): + render_stacktrace_md(event) # just make sure this doesn't crash + urls = [ f'/issues/issue/{ event.issue.id }/event/{ event.id }/', f'/issues/issue/{ event.issue.id }/event/{ event.id }/details/', @@ -552,6 +556,90 @@ class IntegrationTest(TransactionTestCase): # we want to know _which_ event failed, hence the raise-from-e here raise AssertionError("Error rendering event %s" % event.debug_info) from e + def test_render_stacktrace_md(self): + user = User.objects.create_user(username='test', password='test') + project = Project.objects.create(name="test") + ProjectMembership.objects.create(project=project, user=user) + self.client.force_login(user) + + sentry_auth_header = get_header_value(f"http://{ project.sentry_key }@hostisignored/{ project.id }") + # event through the ingestion pipeline + command = SendJsonCommand() + command.stdout = StringIO() + command.stderr = StringIO() + + SAMPLES_DIR = os.getenv("SAMPLES_DIR", "../event-samples") + + # a nice example because it has 4 kinds of frames (some missing source context, some missing local vars) + filename = SAMPLES_DIR + "/bugsink/frames-with-missing-info.json" + + with open(filename) as f: + data = json.loads(f.read()) + + # leave as-is for reproducibility of the test + # data["event_id"] = + + if not command.is_valid(data, filename): + raise Exception("validatity check in %s: %s" % (filename, command.stderr.getvalue())) + + response = self.client.post( + f"/api/{ project.id }/store/", + json.dumps(data), + content_type="application/json", + headers={ + "X-Sentry-Auth": sentry_auth_header, + "X-BugSink-DebugInfo": filename, + }, + ) + self.assertEqual( + 200, response.status_code, "Error in %s: %s" % ( + filename, response.content if response.status_code != 302 else response.url)) + + event = Event.objects.get(issue__project=project, event_id=data["event_id"]) + md = render_stacktrace_md(event, frames="all", exceptions="all", include_locals=True) + + self.assertEqual('''# CapturedStacktraceFo +4 kinds of frames + +### manage.py:22 in `complete_with_both` [in-app] + 17 | ) from exc + 18 | execute_from_command_line(sys.argv) + 19 | + 20 | + 21 | if __name__ == '__main__': +▶ 22 | main() + +#### Locals + +* `__name__` = `'__main__'` +* `__doc__` = `"Django's command-line utility for administrative tasks."` +* `__package__` = `None` +* `__loader__` = `<_frozen_importlib_external.SourceFileLoader object at 0x7fe00fb21810>` +* `__spec__` = `None` +* `__annotations__` = `{}` +* `__builtins__` = `` +* `__file__` = `'/mnt/datacrypt/dev/bugsink/manage.py'` +* `__cached__` = `None` +* `os` = `` + +### manage.py in `missing_code` [in-app] +_no source context available_ + +#### Locals + +* `execute_from_command_line` = `` + +### django/core/management/__init__.py:442 in `missing_vars` [in-app] + 437 | + 438 | + 439 | def execute_from_command_line(argv=None): + 440 | """Run a ManagementUtility.""" + 441 | utility = ManagementUtility(argv) +▶ 442 | utility.execute() + +### django/core/management/__init__.py in `missing_everything` [in-app] +_no source context available_''', md) + class GroupingUtilsTestCase(DjangoTestCase): diff --git a/theme/tests.py b/theme/tests.py index da4f2c6..46b9596 100644 --- a/theme/tests.py +++ b/theme/tests.py @@ -68,7 +68,7 @@ class TestPygmentizeLineLineCountHandling(RegularTestCase): _pygmentize_lines(["\n", "\n", "\n"]) -class TestChooseLexerForPatter(RegularTestCase): +class TestChooseLexerForPattern(RegularTestCase): def test_choose_lexer_for_pattern(self): # simple 'does it not crash' test: