Expose event stacktrace as markdown

Adds a `stacktrace_md` field to EventSerializer and a `/stacktrace` action
returning the same markdown (but as the full response).

Also switches `data` to use `get_parsed_data()` (as it should have) and
in json dict format (rather than str).
This commit is contained in:
Klaas van Schelven
2025-09-15 15:13:02 +02:00
parent bfcbf8005a
commit 5bb4dc1f20
4 changed files with 70 additions and 1 deletions

View File

@@ -1,7 +1,10 @@
from django.shortcuts import get_object_or_404
from rest_framework import viewsets
from rest_framework.exceptions import ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
from rest_framework.decorators import action
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes, OpenApiResponse
from bugsink.utils import assert_
from bugsink.api_pagination import AscDescCursorPagination
@@ -9,6 +12,8 @@ from bugsink.api_mixins import AtomicRequestMixin
from .models import Event
from .serializers import EventListSerializer, EventDetailSerializer
from .markdown_stacktrace import render_stacktrace_md
from .renderers import MarkdownRenderer
class EventPagination(AscDescCursorPagination):
@@ -86,3 +91,18 @@ class EventViewSet(AtomicRequestMixin, viewsets.ReadOnlyModelViewSet):
def get_serializer_class(self):
return EventDetailSerializer if self.action == "retrieve" else EventListSerializer
@extend_schema(
description="Render the event's stacktrace (frames, source, locals) as Markdown-like text.",
responses={200: OpenApiResponse(response=str, description="Stacktrace as Markdown")},
)
@action(
detail=True,
methods=["get"],
url_path="stacktrace",
renderer_classes=[MarkdownRenderer],
)
def stacktrace(self, request, pk=None):
event = self.get_object()
text = render_stacktrace_md(event, frames="in_app", exceptions="last", include_locals=True)
return Response(text)

10
events/renderers.py Normal file
View File

@@ -0,0 +1,10 @@
from rest_framework.renderers import BaseRenderer
class MarkdownRenderer(BaseRenderer):
media_type = "text/markdown"
format = "md"
charset = "utf-8"
def render(self, data, accepted_media_type=None, renderer_context=None):
return data.encode("utf-8")

View File

@@ -1,5 +1,7 @@
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from .markdown_stacktrace import render_stacktrace_md
from .models import Event
@@ -26,9 +28,22 @@ class EventDetailSerializer(serializers.ModelSerializer):
# NOTE as with Issue.grouping_keys: check viewset for prefetching
# grouping_key = serializers.CharField(source="grouping.grouping_key", read_only=True)
data = serializers.SerializerMethodField()
stacktrace_md = serializers.SerializerMethodField()
class Meta:
model = Event
fields = EventListSerializer.Meta.fields + [
"data",
"stacktrace_md",
# "grouping_key" # TODO (likely) once we have the "expand" idea implemented
]
@extend_schema_field(serializers.JSONField)
def get_data(self, obj):
# we override `data` to return the parsed version (which may come from the file store rather than the DB)
return obj.get_parsed_data()
@extend_schema_field(serializers.CharField)
def get_stacktrace_md(self, obj):
return render_stacktrace_md(obj, frames="in_app", exceptions="last", include_locals=True)

View File

@@ -35,6 +35,30 @@ class EventApiTests(TransactionTestCase):
detail = response.json()
self.assertEqual(detail["id"], str(self.event.id))
self.assertIn("data", detail)
self.assertTrue("event_id" in detail["data"])
def test_detail_includes_stacktrace_md_field(self):
url = reverse("api:event-detail", args=[self.event.id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
detail = response.json()
self.assertIn("stacktrace_md", detail)
self.assertIsInstance(detail["stacktrace_md"], str)
self.assertTrue(len(detail["stacktrace_md"]) > 0)
self.assertEqual("_No stacktrace available._", detail["stacktrace_md"])
def test_stacktrace_action_returns_markdown(self):
url = reverse("api:event-stacktrace", args=[self.event.id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTrue(response["Content-Type"].startswith("text/markdown"))
body = response.content.decode("utf-8")
self.assertTrue(len(body) > 0)
self.assertEqual("_No stacktrace available._", body)
def test_list_by_issue_is_light_payload(self):
response = self.client.get(reverse("api:event-list"), {"issue": str(self.issue.id)})