From 5bb4dc1f20e8e5e9b18d59d4c4851d58a16e50ce Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Mon, 15 Sep 2025 15:13:02 +0200 Subject: [PATCH] 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). --- events/api_views.py | 22 +++++++++++++++++++++- events/renderers.py | 10 ++++++++++ events/serializers.py | 15 +++++++++++++++ events/test_api.py | 24 ++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 events/renderers.py diff --git a/events/api_views.py b/events/api_views.py index 7295695..b7571a0 100644 --- a/events/api_views.py +++ b/events/api_views.py @@ -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) diff --git a/events/renderers.py b/events/renderers.py new file mode 100644 index 0000000..348f1c6 --- /dev/null +++ b/events/renderers.py @@ -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") diff --git a/events/serializers.py b/events/serializers.py index 9b7ee45..3e91fd0 100644 --- a/events/serializers.py +++ b/events/serializers.py @@ -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) diff --git a/events/test_api.py b/events/test_api.py index 3cdc3ef..8299c18 100644 --- a/events/test_api.py +++ b/events/test_api.py @@ -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)})