sourcemaps
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m5s

This commit is contained in:
mats committed 2026-09-19 16:24:34 +02:00
1 parent 97743ea21c
commit 1aa264675e
11 files changed
+538 -17

No files matched your search

+31 -8
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from "react"
import { Bug, ChevronDown, ChevronUp } from "lucide-react"
import { triggerTestError } from "../lib/test-error"
import { authFetch } from "../lib/auth"
import { formatRelativeTime } from "../lib/timeUtils"
import { SourceFrameViewer, type StackFrame } from "./SourceFrameViewer"
import type { RumErrorEvent, RumSession } from "../lib/rum-utils"
interface JsErrorGroup {
@@ -38,6 +40,7 @@ const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => {
/** Maps each group's sample stacktrace through the server's source maps; falls back to the raw stack. */
const useResolvedStacks = (groups: JsErrorGroup[]) => {
const [resolved, setResolved] = useState<Record<string, string>>({})
const [frames, setFrames] = useState<Record<string, StackFrame[]>>({})
const items = useMemo(
() => groups.filter((g) => g.sample?.stacktrace).map((g) => ({ key: g.key, release: g.sample!.release ?? "", stacktrace: g.sample!.stacktrace! })),
[groups],
@@ -55,9 +58,10 @@ const useResolvedStacks = (groups: JsErrorGroup[]) => {
body: JSON.stringify({ items: items.map(({ release, stacktrace }) => ({ release, stacktrace })) }),
})
if (!response.ok) return
const body = (await response.json()) as { resolved: string[] }
const body = (await response.json()) as { resolved: string[]; frames?: StackFrame[][] }
if (cancelled) return
setResolved(Object.fromEntries(items.map((item, i) => [item.key, body.resolved[i] ?? item.stacktrace])))
setFrames(Object.fromEntries(items.map((item, i) => [item.key, body.frames?.[i] ?? []])))
} catch {
/* raw stacks still render */
}
@@ -66,15 +70,16 @@ const useResolvedStacks = (groups: JsErrorGroup[]) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signature])
return resolved
return { resolved, frames }
}
const JsErrorCard = ({ group, resolvedStack }: { group: JsErrorGroup; resolvedStack?: string }) => {
const JsErrorCard = ({ group, resolvedStack, frames }: { group: JsErrorGroup; resolvedStack?: string; frames: StackFrame[] }) => {
const [expanded, setExpanded] = useState(false)
const [showRaw, setShowRaw] = useState(false)
const raw = group.sample?.stacktrace
const mapped = Boolean(raw && resolvedStack && resolvedStack !== raw)
const stack = showRaw || !mapped ? raw : resolvedStack
const explorable = frames.length > 0 && frames.some((f) => f.status === "mapped")
return (
<article className="flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] border-l-ds-critical bg-ds-surface">
@@ -106,13 +111,19 @@ const JsErrorCard = ({ group, resolvedStack }: { group: JsErrorGroup; resolvedSt
<button type="button" onClick={() => setShowRaw((v) => !v)} className="text-[11.5px] text-ds-text-3 hover:text-ds-text-2">{showRaw ? "Show mapped" : "Show raw"}</button>
)}
</div>
{stack ? (
{explorable && !showRaw ? (
<SourceFrameViewer frames={frames} />
) : stack ? (
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-[10px] border border-ds-line bg-ds-bg p-3 font-[family-name:var(--font-data)] text-[11px] leading-relaxed text-ds-text-2">{stack}</pre>
) : (
<p className="text-[12px] text-ds-text-3">No stacktrace was reported for this error.</p>
)}
{!mapped && raw && !group.sample?.release && (
<p className="text-[11.5px] text-ds-text-3">No release on this event, so source maps can't be applied.</p>
{!explorable && raw && (
<p className="text-[11.5px] text-ds-text-3">
{!group.sample?.release
? "No release on this event, so source maps can't be applied."
: "No source map matched this stack. Upload one for this release via POST /api/sourcemaps to see original source."}
</p>
)}
</div>
)}
@@ -120,17 +131,29 @@ const JsErrorCard = ({ group, resolvedStack }: { group: JsErrorGroup; resolvedSt
)
}
const TestErrorButton = () => (
<button
type="button"
onClick={triggerTestError}
title="Throws an uncaught error from this UI to verify RUM reporting and source maps end to end"
className="inline-flex h-8 items-center gap-1.5 self-end rounded-[9px] border border-ds-line-strong px-2.5 text-[12px] font-medium text-ds-text-2 hover:text-ds-text"
>
<Bug className="h-3.5 w-3.5" aria-hidden="true" /> Generate test error
</button>
)
export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => {
const groups = useMemo(() => groupJsErrors(sessions), [sessions])
const resolved = useResolvedStacks(groups)
const { resolved, frames } = useResolvedStacks(groups)
if (groups.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-16 text-center">
<Bug className="h-8 w-8 text-ds-ok/60" aria-hidden="true" />
<p className="text-[14px] font-medium text-ds-text">No JavaScript errors</p>
<TestErrorButton />
</div>
)
}
return <div className="flex flex-col gap-3">{groups.map((g) => <JsErrorCard key={g.key} group={g} resolvedStack={resolved[g.key]} />)}</div>
return <div className="flex flex-col gap-3"><TestErrorButton />{groups.map((g) => <JsErrorCard key={g.key} group={g} resolvedStack={resolved[g.key]} frames={frames[g.key] ?? []} />)}</div>
}
@@ -0,0 +1,144 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { EditorState, RangeSetBuilder } from "@codemirror/state"
import { Decoration, EditorView, lineNumbers, type DecorationSet } from "@codemirror/view"
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"
import { javascript } from "@codemirror/lang-javascript"
import { tags as t } from "@lezer/highlight"
import { Info } from "lucide-react"
export interface StackFrame {
raw: string
function?: string
file: string
line: number
column: number
status: "mapped" | "no-release" | "no-map" | "unmapped"
source?: string
name?: string
srcLine?: number
srcColumn?: number
context?: string[]
contextTop?: number
}
const statusHint: Record<StackFrame["status"], string> = {
mapped: "Mapped to original source",
"no-release": "This event has no release, so source maps can't be looked up.",
"no-map": "No source map uploaded for this file and release. POST it to /api/sourcemaps to see original source.",
unmapped: "A source map exists but has no mapping for this position.",
}
const statusDot: Record<StackFrame["status"], string> = {
mapped: "bg-ds-ok",
"no-release": "bg-ds-text-3",
"no-map": "bg-ds-warn",
unmapped: "bg-ds-warn",
}
const highlight = HighlightStyle.define([
{ tag: [t.keyword, t.modifier, t.operatorKeyword], color: "var(--ds-accent)" },
{ tag: [t.string, t.regexp], color: "var(--ds-ok)" },
{ tag: [t.number, t.bool, t.null], color: "var(--ds-warn)" },
{ tag: [t.comment], color: "var(--ds-text-3)", fontStyle: "italic" },
{ tag: [t.function(t.variableName), t.definition(t.variableName)], color: "var(--ds-text)" },
{ tag: [t.typeName, t.className], color: "var(--ds-accent)" },
])
const theme = EditorView.theme({
"&": { backgroundColor: "var(--ds-bg)", color: "var(--ds-text-2)", fontSize: "12px" },
".cm-content": { fontFamily: "var(--font-data, ui-monospace, monospace)", padding: "6px 0" },
".cm-gutters": { backgroundColor: "var(--ds-bg)", color: "var(--ds-text-3)", border: "none" },
".cm-scroller": { overflow: "auto" },
".cm-error-line": { backgroundColor: "var(--ds-critical-bg)", boxShadow: "inset 3px 0 0 var(--ds-critical)" },
".cm-error-col": { backgroundColor: "color-mix(in srgb, var(--ds-critical) 35%, transparent)", borderBottom: "2px solid var(--ds-critical)", borderRadius: "2px" },
})
/** Highlights the throwing line and the exact column the source map points at. */
const errorDecorations = (state: EditorState, line: number, col: number, len: number): DecorationSet => {
const b = new RangeSetBuilder<Decoration>()
if (line < 1 || line > state.doc.lines) return b.finish()
const l = state.doc.line(line)
b.add(l.from, l.from, Decoration.line({ class: "cm-error-line" }))
const from = Math.min(l.from + Math.max(col, 0), l.to)
const to = Math.min(from + Math.max(len, 1), l.to)
if (to > from) b.add(from, to, Decoration.mark({ class: "cm-error-col" }))
return b.finish()
}
const Editor = ({ frame }: { frame: StackFrame }) => {
const host = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!host.current || !frame.context || !frame.srcLine) return
const top = frame.contextTop ?? 1
const relLine = frame.srcLine - top + 1
const state = EditorState.create({
doc: frame.context.join("\n"),
extensions: [
EditorState.readOnly.of(true),
EditorView.editable.of(false),
lineNumbers({ formatNumber: (n) => String(n + top - 1) }),
javascript({ jsx: true, typescript: /\.tsx?$/.test(frame.source ?? "") }),
syntaxHighlighting(highlight),
theme,
EditorView.decorations.compute([], (s) =>
errorDecorations(s, relLine, (frame.srcColumn ?? 1) - 1, frame.name?.length || 1),
),
],
})
const view = new EditorView({ state, parent: host.current })
return () => view.destroy()
}, [frame])
return <div ref={host} className="overflow-hidden rounded-[10px] border border-ds-line" />
}
export const SourceFrameViewer = ({ frames }: { frames: StackFrame[] }) => {
const firstMapped = useMemo(() => Math.max(frames.findIndex((f) => f.status === "mapped" && f.context), 0), [frames])
const [picked, setPicked] = useState<number | null>(null)
const index = picked ?? firstMapped
const frame = frames[index]
if (!frame) return null
return (
<div className="flex flex-col gap-3 lg:flex-row">
<ol className="flex max-h-80 min-w-0 flex-col gap-1 overflow-auto lg:w-[42%] lg:shrink-0">
{frames.map((f, i) => (
<li key={i}>
<button
type="button"
onClick={() => setPicked(i)}
aria-current={i === index ? "true" : undefined}
className={`flex w-full items-start gap-2 rounded-[9px] border px-2.5 py-1.5 text-left hover:border-ds-accent/40 ${i === index ? "border-ds-accent/50 bg-ds-surface-2" : "border-ds-line bg-ds-surface"}`}
>
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${statusDot[f.status]}`} aria-hidden="true" />
<span className="flex min-w-0 flex-col">
<span className="truncate font-[family-name:var(--font-data)] text-[12px] font-semibold text-ds-text">{f.name || f.function || "<anonymous>"}</span>
<span className="break-all font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
{f.status === "mapped" ? `${f.source}:${f.srcLine}:${f.srcColumn}` : `${f.file.replace(/^https?:\/\/[^/]+/, "")}:${f.line}:${f.column}`}
</span>
</span>
</button>
</li>
))}
</ol>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{frame.status === "mapped" && frame.context ? (
<>
<p className="break-all font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">{frame.source}</p>
<Editor frame={frame} />
</>
) : null}
<p className="flex items-start gap-1.5 text-[11.5px] text-ds-text-3">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>
{frame.status === "mapped" && !frame.context
? "Mapped, but the source map has no embedded sourcesContent. Rebuild with sourcesContent enabled to see the code here."
: statusHint[frame.status]}
{frame.status === "mapped" && (
<span className="font-[family-name:var(--font-data)]"> · generated {frame.file.replace(/^https?:\/\/[^/]+/, "")}:{frame.line}:{frame.column}</span>
)}
</span>
</p>
</div>
</div>
)
}