From 9d4ccf44ae84dcf3c311053750d9ef67835889ab Mon Sep 17 00:00:00 2001 From: ajianaz Date: Tue, 13 Oct 2026 08:32:49 +0700 Subject: [PATCH] feat(ui): structured event detail with stack trace, breadcrumbs, tags, request, user Render exception frames (in-app highlighted, library frames collapsed), breadcrumb timeline, tag chips, request, user and environment contexts. Keep a Raw JSON tab, add newer/older/latest navigation and a 'latest' event route alias. Parsing helpers are defensive and unit tested. Closes #339 Co-Authored-By: Claude Sonnet 5.5 --- web/src/lib/components/EventDetail.svelte | 295 ++++++++++++++++++ web/src/lib/event-detail.test.ts | 137 ++++++++ web/src/lib/event-detail.ts | 248 +++++++++++++++ .../(dashboard)/issues/[issueId]/+page.svelte | 7 +- .../[issueId]/events/[eventId]/+page.svelte | 114 +++++-- 5 files changed, 767 insertions(+), 34 deletions(-) create mode 100644 web/src/lib/components/EventDetail.svelte create mode 100644 web/src/lib/event-detail.test.ts create mode 100644 web/src/lib/event-detail.ts diff --git a/web/src/lib/components/EventDetail.svelte b/web/src/lib/components/EventDetail.svelte new file mode 100644 index 0000000..757b282 --- /dev/null +++ b/web/src/lib/components/EventDetail.svelte @@ -0,0 +1,295 @@ + + +{#snippet kvTable(rows: KeyValue[], label: string)} +
+ {#each rows as row (row.key)} +
{row.key}
+
{row.value}
+ {/each} +
+{/snippet} + +
+
+ {#each [['details', 'Details'], ['raw', 'Raw JSON']] as [id, label] (id)} + + {/each} +
+ + {#if tab === 'details'} +
+ {#if !hasAnything} +

+ This event has no structured data. Check the Raw JSON tab. +

+ {/if} + + {#if message} + + Message +

{message}

+
+ {/if} + + {#each exceptions as exc, ei (ei)} + + + + {exc.type}{#if exc.value}: {exc.value}{/if} + + + + {#if exc.frames.length === 0} +

No stack trace available.

+ {:else} +
    + {#each groupFrames(exc.frames) as group, gi (gi)} + {@const key = `${ei}-${gi}`} + {#if group.collapsed && !expanded[key]} +
  1. + +
  2. + {:else} + {#if group.collapsed} +
  3. + +
  4. + {/if} + {#each group.frames as f, fi (fi)} +
  5. +
    + {frameLocation(f)} + {#if f.function}in {f.function}{/if} + {#if f.inApp}in app{/if} +
    + {#if f.contextLine !== null} +
    {#each f.preContext as l}{l}{/each}{f.contextLine}{#each f.postContext as l}{l}{/each}
    + {/if} +
  6. + {/each} + {/if} + {/each} +
+ {/if} +
+
+ {/each} + + {#if breadcrumbs.length > 0} + + Breadcrumbs ({breadcrumbs.length}) + +
    + {#each breadcrumbs as b, i (i)} +
  1. + +
    + {#if b.timestamp}{/if} + {b.category || b.type} + {b.level} +
    + {#if b.message}

    {b.message}

    {/if} + {#if isDict(b.data) && Object.keys(b.data).length > 0} +

    {formatValue(b.data)}

    + {/if} +
  2. + {/each} +
+
+
+ {/if} + + {#if tags.length > 0} + + Tags + +
    + {#each tags as t (t.key)} +
  • + {t.key} + {t.value} +
  • + {/each} +
+
+
+ {/if} + + {#if request} + + Request + + {#if request.url || request.method} +

+ {#if request.method}{request.method}{/if}{request.url}{#if request.query}?{request.query}{/if} +

+ {/if} + {#if request.headers.length > 0} +

Headers

+ {@render kvTable(request.headers, 'Request headers')} + {/if} + {#if request.rows.length > 0} + {@render kvTable(request.rows, 'Request details')} + {/if} +
+
+ {/if} + + {#if user.length > 0} + + User + {@render kvTable(user, 'User')} + + {/if} + + {#if summary.length > 0 || contexts.length > 0} + + Environment + + {#if summary.length > 0}{@render kvTable(summary, 'Release and environment')}{/if} +
+ {#each contexts as c (c.name)} +
+

{c.name}

+ {@render kvTable(c.rows, `${c.name} context`)} +
+ {/each} +
+
+
+ {/if} + + {#if extra.length > 0} + + Additional data + {@render kvTable(extra, 'Additional data')} + + {/if} +
+ {:else} +
+ + + Raw JSON + + + + + +
{raw}
+
+
+
+ {/if} +
diff --git a/web/src/lib/event-detail.test.ts b/web/src/lib/event-detail.test.ts new file mode 100644 index 0000000..6aa0312 --- /dev/null +++ b/web/src/lib/event-detail.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import { + frameLocation, + groupFrames, + neighbours, + parseBreadcrumbs, + parseContexts, + parseExceptions, + parseRequest, + parseSummary, + parseTags, + parseUser, + type Frame +} from './event-detail'; + +const frame = (inApp: boolean, over: Partial = {}): Frame => ({ + filename: 'f', + function: '', + module: '', + lineno: null, + colno: null, + inApp, + preContext: [], + contextLine: null, + postContext: [], + ...over +}); + +describe('parseExceptions', () => { + it('returns [] for missing data', () => { + expect(parseExceptions(null)).toEqual([]); + expect(parseExceptions({})).toEqual([]); + expect(parseExceptions({ exception: 'nope' })).toEqual([]); + }); + + it('reverses frames so the latest call is first and tolerates partial frames', () => { + const out = parseExceptions({ + exception: { + values: [ + { + type: 'TypeError', + value: 'x is undefined', + stacktrace: { + frames: [{ filename: 'a.js', in_app: false }, { function: 'main', lineno: 3, in_app: true }, null, 5] + } + } + ] + } + }); + expect(out).toHaveLength(1); + expect(out[0].type).toBe('TypeError'); + expect(out[0].frames.map((f) => f.function)).toEqual(['main', '']); + expect(out[0].frames[0].inApp).toBe(true); + }); + + it('accepts a bare array and defaults the type', () => { + const out = parseExceptions({ exception: [{ value: 'boom' }] }); + expect(out[0]).toMatchObject({ type: 'Error', value: 'boom', frames: [] }); + }); +}); + +describe('frames', () => { + it('groups consecutive library frames', () => { + const g = groupFrames([frame(true), frame(false), frame(false), frame(true)]); + expect(g.map((x) => [x.collapsed, x.frames.length])).toEqual([ + [false, 1], + [true, 2], + [false, 1] + ]); + }); + it('never collapses a trace without in-app frames', () => { + expect(groupFrames([frame(false), frame(false)]).every((g) => !g.collapsed)).toBe(true); + }); + it('formats location', () => { + expect(frameLocation(frame(true, { filename: 'a.ts', lineno: 1, colno: 2 }))).toBe('a.ts:1:2'); + expect(frameLocation(frame(true, { filename: 'a.ts', lineno: 1 }))).toBe('a.ts:1'); + expect(frameLocation(frame(true, { filename: '' }))).toBe(''); + }); +}); + +describe('parseBreadcrumbs', () => { + it('handles values wrapper, numeric timestamps and missing fields', () => { + const out = parseBreadcrumbs({ + breadcrumbs: { values: [{ timestamp: 0, category: 'ui.click', message: 'hi' }, { type: 'http' }, 'junk'] } + }); + expect(out).toHaveLength(2); + expect(out[0].timestamp).toBe('1970-01-01T00:00:00.000Z'); + expect(out[0].type).toBe('default'); + expect(out[1].timestamp).toBeNull(); + expect(parseBreadcrumbs({})).toEqual([]); + }); +}); + +describe('parseTags', () => { + it('parses object and pair-array forms', () => { + expect(parseTags({ tags: { a: 1, b: 'x' } })).toEqual([ + { key: 'a', value: '1' }, + { key: 'b', value: 'x' } + ]); + expect(parseTags({ tags: [['a', 'b']] })).toEqual([{ key: 'a', value: 'b' }]); + expect(parseTags({ tags: null })).toEqual([]); + }); +}); + +describe('request/user/contexts/summary', () => { + it('returns null/empty when absent', () => { + expect(parseRequest({})).toBeNull(); + expect(parseRequest({ request: {} })).toBeNull(); + expect(parseUser({})).toEqual([]); + expect(parseContexts({ contexts: 3 })).toEqual([]); + }); + it('parses a request', () => { + const r = parseRequest({ request: { method: 'post', url: 'http://x/y', headers: { A: 'b' }, query_string: 'q=1' } }); + expect(r).toMatchObject({ method: 'POST', url: 'http://x/y', query: 'q=1' }); + expect(r?.headers).toEqual([{ key: 'A', value: 'b' }]); + }); + it('parses user and contexts', () => { + expect(parseUser({ user: { id: '1', email: '' } })).toEqual([{ key: 'id', value: '1' }]); + const c = parseContexts({ contexts: { os: { name: 'macOS', type: 'os' }, empty: {} } }); + expect(c).toEqual([{ name: 'os', rows: [{ key: 'name', value: 'macOS' }] }]); + }); + it('builds a summary', () => { + expect(parseSummary({ release: '1.0', sdk: { name: 's', version: '2' } })).toEqual([ + { key: 'release', value: '1.0' }, + { key: 'sdk', value: 's 2' } + ]); + }); +}); + +describe('neighbours', () => { + const list = [{ id: 'c' }, { id: 'b' }, { id: 'a' }]; + it('finds newer and older', () => { + expect(neighbours(list, 'b')).toMatchObject({ index: 1, newer: { id: 'c' }, older: { id: 'a' } }); + expect(neighbours(list, 'c').newer).toBeNull(); + expect(neighbours(list, 'zz').index).toBe(-1); + }); +}); diff --git a/web/src/lib/event-detail.ts b/web/src/lib/event-detail.ts new file mode 100644 index 0000000..5797e46 --- /dev/null +++ b/web/src/lib/event-detail.ts @@ -0,0 +1,248 @@ +/** + * Defensive parsers for the stored event JSON. Sentry events vary a lot between + * SDKs, so every helper tolerates missing, null or wrongly typed fields. + */ + +export type Dict = Record; + +export interface Frame { + filename: string; + function: string; + module: string; + lineno: number | null; + colno: number | null; + inApp: boolean; + preContext: string[]; + contextLine: string | null; + postContext: string[]; +} + +export interface ParsedException { + type: string; + value: string; + frames: Frame[]; +} + +export interface ParsedBreadcrumb { + timestamp: string | null; + type: string; + category: string; + message: string; + level: string; + data: unknown; +} + +export interface KeyValue { + key: string; + value: string; +} + +export function isDict(v: unknown): v is Dict { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function str(v: unknown): string { + return typeof v === 'string' ? v : v == null ? '' : String(v); +} + +function num(v: unknown): number | null { + return typeof v === 'number' && Number.isFinite(v) ? v : null; +} + +function strArray(v: unknown): string[] { + return Array.isArray(v) ? v.map(str) : []; +} + +/** Sentry wraps lists as `{ values: [...] }`; some SDKs send a bare array. */ +function unwrapValues(v: unknown): unknown[] { + if (Array.isArray(v)) return v; + if (isDict(v) && Array.isArray(v.values)) return v.values; + return []; +} + +/** Render any value as a short human string. Objects become compact JSON. */ +export function formatValue(v: unknown): string { + if (v == null) return ''; + if (typeof v === 'string') return v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +export function parseFrame(raw: unknown): Frame | null { + if (!isDict(raw)) return null; + const filename = str(raw.filename) || str(raw.abs_path); + return { + filename, + function: str(raw.function), + module: str(raw.module), + lineno: num(raw.lineno), + colno: num(raw.colno), + inApp: raw.in_app === true, + preContext: strArray(raw.pre_context), + contextLine: typeof raw.context_line === 'string' ? raw.context_line : null, + postContext: strArray(raw.post_context) + }; +} + +/** + * Parse exceptions. Frames are returned most recent call first (Sentry sends + * them oldest first), which is the order developers read them in. + */ +export function parseExceptions(data: Dict | null | undefined): ParsedException[] { + if (!data) return []; + return unwrapValues(data.exception) + .filter(isDict) + .map((e) => { + const st = isDict(e.stacktrace) ? e.stacktrace : null; + const rawFrames: unknown[] = st && Array.isArray(st.frames) ? st.frames : []; + const frames = rawFrames + .map(parseFrame) + .filter((f): f is Frame => f !== null) + .reverse(); + return { type: str(e.type) || 'Error', value: str(e.value), frames }; + }); +} + +/** Label for a frame location, e.g. `src/app.ts:12:4`. */ +export function frameLocation(f: Frame): string { + const base = f.filename || f.module || ''; + if (f.lineno == null) return base; + return f.colno == null ? `${base}:${f.lineno}` : `${base}:${f.lineno}:${f.colno}`; +} + +/** Group consecutive library frames so they can be collapsed. */ +export type FrameGroup = { collapsed: boolean; frames: Frame[] }; + +export function groupFrames(frames: Frame[]): FrameGroup[] { + const groups: FrameGroup[] = []; + for (const f of frames) { + const collapsed = !f.inApp; + const last = groups[groups.length - 1]; + if (last && last.collapsed === collapsed) last.frames.push(f); + else groups.push({ collapsed, frames: [f] }); + } + // A trace with no in-app frames at all should not be hidden entirely. + if (groups.length > 0 && groups.every((g) => g.collapsed)) { + groups.forEach((g) => (g.collapsed = false)); + } + return groups; +} + +/** Breadcrumbs in the order received (oldest first). */ +export function parseBreadcrumbs(data: Dict | null | undefined): ParsedBreadcrumb[] { + if (!data) return []; + return unwrapValues(data.breadcrumbs) + .filter(isDict) + .map((b) => ({ + timestamp: + typeof b.timestamp === 'string' + ? b.timestamp + : typeof b.timestamp === 'number' + ? new Date(b.timestamp * 1000).toISOString() + : null, + type: str(b.type) || 'default', + category: str(b.category), + message: str(b.message), + level: str(b.level) || 'info', + data: b.data ?? null + })); +} + +/** Tags arrive as an object or as `[[key, value], ...]`. */ +export function parseTags(data: Dict | null | undefined): KeyValue[] { + const raw = data?.tags; + const out: KeyValue[] = []; + if (Array.isArray(raw)) { + for (const item of raw) { + if (Array.isArray(item) && item.length >= 2) out.push({ key: str(item[0]), value: formatValue(item[1]) }); + else if (isDict(item) && 'key' in item) out.push({ key: str(item.key), value: formatValue(item.value) }); + } + } else if (isDict(raw)) { + for (const [key, value] of Object.entries(raw)) out.push({ key, value: formatValue(value) }); + } + return out; +} + +/** Flatten one level of an object into key/value rows (nested values as JSON). */ +export function toRows(v: unknown): KeyValue[] { + if (!isDict(v)) return []; + return Object.entries(v) + .filter(([, val]) => val !== null && val !== undefined && val !== '') + .map(([key, val]) => ({ key, value: formatValue(val) })); +} + +export interface ParsedRequest { + method: string; + url: string; + headers: KeyValue[]; + query: string; + rows: KeyValue[]; +} + +export function parseRequest(data: Dict | null | undefined): ParsedRequest | null { + const r = data?.request; + if (!isDict(r) || Object.keys(r).length === 0) return null; + let headers: KeyValue[] = []; + if (isDict(r.headers)) headers = toRows(r.headers); + else if (Array.isArray(r.headers)) + headers = r.headers + .filter((h): h is unknown[] => Array.isArray(h)) + .map((h) => ({ key: str(h[0]), value: formatValue(h[1]) })); + const query = + typeof r.query_string === 'string' + ? r.query_string + : isDict(r.query_string) + ? new URLSearchParams(r.query_string as Record).toString() + : ''; + const rows = toRows({ env: r.env, cookies: r.cookies, data: r.data }).filter((x) => x.value !== '{}'); + return { method: str(r.method).toUpperCase(), url: str(r.url), headers, query, rows }; +} + +export function parseUser(data: Dict | null | undefined): KeyValue[] { + const ctx = data?.contexts; + const u = data?.user ?? (isDict(ctx) ? ctx.user : undefined); + return toRows(u); +} + +/** Contexts keyed by name (os, device, runtime, ...), each flattened to rows. */ +export function parseContexts(data: Dict | null | undefined): { name: string; rows: KeyValue[] }[] { + const c = data?.contexts; + if (!isDict(c)) return []; + return Object.entries(c) + .map(([name, v]) => { + const rows = toRows(isDict(v) ? v : { value: v }).filter((r) => r.key !== 'type'); + return { name, rows }; + }) + .filter((ctx) => ctx.rows.length > 0); +} + +/** Top-level facts: release, environment, platform, server name, SDK. */ +export function parseSummary(data: Dict | null | undefined): KeyValue[] { + if (!data) return []; + const sdk = isDict(data.sdk) ? [str(data.sdk.name), str(data.sdk.version)].filter(Boolean).join(' ') : ''; + return toRows({ + release: data.release, + environment: data.environment, + platform: data.platform, + server_name: data.server_name, + sdk + }); +} + +export function parseExtra(data: Dict | null | undefined): KeyValue[] { + return toRows(data?.extra); +} + +/** Resolve neighbours in a newest-first list. `newer` precedes, `older` follows. */ +export function neighbours( + list: T[], + id: string +): { index: number; newer: T | null; older: T | null } { + const index = list.findIndex((e) => e.id === id); + if (index < 0) return { index, newer: null, older: null }; + return { index, newer: list[index - 1] ?? null, older: list[index + 1] ?? null }; +} diff --git a/web/src/routes/(dashboard)/issues/[issueId]/+page.svelte b/web/src/routes/(dashboard)/issues/[issueId]/+page.svelte index 99b990a..e76e51d 100644 --- a/web/src/routes/(dashboard)/issues/[issueId]/+page.svelte +++ b/web/src/routes/(dashboard)/issues/[issueId]/+page.svelte @@ -266,7 +266,12 @@

Events

{#if totalEvents > 0} - {totalEvents} total +
+ {totalEvents} total + +
{/if}
diff --git a/web/src/routes/(dashboard)/issues/[issueId]/events/[eventId]/+page.svelte b/web/src/routes/(dashboard)/issues/[issueId]/events/[eventId]/+page.svelte index 481e188..ddd7026 100644 --- a/web/src/routes/(dashboard)/issues/[issueId]/events/[eventId]/+page.svelte +++ b/web/src/routes/(dashboard)/issues/[issueId]/events/[eventId]/+page.svelte @@ -8,6 +8,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js'; import { Skeleton } from '$lib/components/ui/skeleton/index.js'; import AttachmentCard from '$lib/components/AttachmentCard.svelte'; + import EventDetail from '$lib/components/EventDetail.svelte'; + import { neighbours } from '$lib/event-detail'; import { levelTextClass, statusTextClass, timeAgo } from '$lib/utils'; @@ -19,6 +21,46 @@ let error = $state(''); let projectSlug = $state(''); + let events: StoredEvent[] = $state([]); + let issueId = ''; + + const currentId: string | null = $derived((event as StoredEvent | null)?.id ?? null); + const nav: { index: number; newer: StoredEvent | null; older: StoredEvent | null } = $derived( + currentId ? neighbours(events, currentId) : { index: -1, newer: null, older: null } + ); + + function eventHref(id: string) { + const qs = projectSlug ? '?project=' + projectSlug : ''; + return `/issues/${issueId}/events/${id}${qs}`; + } + + async function loadAttachments(id: string) { + attachmentsLoading = true; + try { + attachments = await api.listAttachments(id); + } catch { + // Silently handle attachment fetch errors + attachments = []; + } finally { + attachmentsLoading = false; + } + } + + async function showEvent(eventId: string) { + const found = events.find((e) => e.id === eventId) || null; + if (!found) { + error = 'Event not found'; + return; + } + event = found; + await loadAttachments(found.id); + } + + async function go(target: StoredEvent | null) { + if (!target) return; + await goto(eventHref(target.id), { replaceState: false, noScroll: true }); + await showEvent(target.id); + } function goBack() { if (projectSlug) { @@ -29,44 +71,42 @@ } function handleKeydown(e: KeyboardEvent) { + const el = e.target as HTMLElement | null; + if (el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.key === 'Escape') { goBack(); } } onMount(async () => { - const issueId = page.params.issueId; - const eventId = page.params.eventId; - if (!issueId || !eventId) return; + const id = page.params.issueId; + const requested = page.params.eventId; + if (!id || !requested) return; + issueId = id; projectSlug = page.url.searchParams.get('project') || ''; try { - // Load issue and events in parallel - const [issueData, eventsRes] = await Promise.all([ - api.getIssue(issueId), - api.listEvents(issueId) - ]); + // Load issue and events in parallel (events are newest first) + const [issueData, eventsRes] = await Promise.all([api.getIssue(id), api.listEvents(id, 1, 100)]); issue = issueData; - event = eventsRes.data.find((e) => e.id === eventId) || null; + events = eventsRes.data; - if (!event) { + // "latest" is a valid event id: resolve to the newest event. + const targetId = requested === 'latest' ? events[0]?.id : requested; + const found = events.find((e) => e.id === targetId) || null; + if (!found) { error = 'Event not found'; loading = false; return; } - - // Fetch attachments for this event - attachmentsLoading = true; - try { - attachments = await api.listAttachments(eventId); - } catch { - // Silently handle attachment fetch errors - attachments = []; - } finally { - attachmentsLoading = false; + event = found; + if (requested === 'latest') { + await goto(eventHref(found.id), { replaceState: true, noScroll: true }); } + await loadAttachments(found.id); } catch (e: any) { error = e?.message || 'Failed to load event'; } finally { @@ -123,20 +163,28 @@

{issue.title}

+ + + - - - - Event Data - - - - {#if event.data?.message} -

{event.data.message as string}

- {/if} -
{JSON.stringify(event.data, null, 2)}
-
-
+ {#key event.id} + + {/key} {#if attachmentsLoading}