mirror of
https://github.com/jlengrand/bugsink.git
synced 2026-03-10 08:01:17 +00:00
markdown_stacktrace util: display an event's stacktrace in markdown
This commit is contained in:
178
events/markdown_stacktrace.py
Normal file
178
events/markdown_stacktrace.py
Normal file
@@ -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 "<unknown>"
|
||||
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()
|
||||
@@ -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__` = `<module 'builtins' (built-in)>`
|
||||
* `__file__` = `'/mnt/datacrypt/dev/bugsink/manage.py'`
|
||||
* `__cached__` = `None`
|
||||
* `os` = `<module 'os' from '/usr/lib/python3.10/os.py'>`
|
||||
|
||||
### manage.py in `missing_code` [in-app]
|
||||
_no source context available_
|
||||
|
||||
#### Locals
|
||||
|
||||
* `execute_from_command_line` = `<function execute_from_command_line at 0x7fe00ec72f80>`
|
||||
|
||||
### 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):
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user