sourcemap view
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m4s

This commit is contained in:
mats committed 2026-09-19 15:49:44 +02:00
1 parent 412e430cac
commit 97743ea21c
10 files changed
+452 -147

No files matched your search

+1
View File
@@ -293,6 +293,7 @@ func main() {
// Source-map uploads are intentionally public so the release build script
// can upload maps without an OIDC session or bearer token.
uiMux.HandleFunc("POST /api/sourcemaps", coll.HandleSourceMap)
uiMux.Handle("POST /api/sourcemaps/resolve", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleResolveStacks)))
uiMux.HandleFunc("GET /api/needle/status", coll.HandleNeedleStatus)
uiMux.HandleFunc("POST /api/needle/classify", coll.HandleNeedleClassify)
+25
View File
@@ -500,6 +500,31 @@ func (c *Collector) HandleSourceMap(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
}
// HandleResolveStacks maps raw browser stacktraces back to source using the
// uploaded source maps. Body: {"items":[{"release":"...","stacktrace":"..."}]};
// the response has one resolved stacktrace per item, in order.
func (c *Collector) HandleResolveStacks(w http.ResponseWriter, r *http.Request) {
var req struct {
Items []struct {
Release string `json:"release"`
Stacktrace string `json:"stacktrace"`
} `json:"items"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&req); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
out := make([]string, len(req.Items))
for i, it := range req.Items {
out[i] = it.Stacktrace
if c.sourceMaps != nil {
out[i] = c.sourceMaps.ResolveStack(it.Release, it.Stacktrace)
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"resolved": out})
}
// HandleIssueByID returns a single issue by id, including its samples.
func (c *Collector) HandleIssueByID(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
+3 -1
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react";
import { AlertCircle, CloudCog, Lock } from "lucide-react";
import { useObservabilityData } from "./hooks/useObservabilityData";
import { IconRail } from "./components/layout/IconRail";
import { AnomalyRadarBar } from "./components/AnomalyRadarBar";
import { TopBar } from "./components/layout/TopBar";
import { CommandAndQueryBar } from "./components/CommandAndQueryBar";
import { WaveformHistogram } from "./components/WaveformHistogram";
@@ -91,10 +92,11 @@ function App() {
{/* Main Telemetry Console View */}
<main className="relative w-full bg-ds-bg pb-12 pt-[58px]">
{!isTriageOverview && <AnomalyRadarBar />}
<div className="flex w-full flex-col px-6">
{!isTriageOverview && (
<>
{/* Real Anomaly Radar & Filter Bar */}
{/* Filter Bar */}
<CommandAndQueryBar />
{/* Ingest & Incident Waveform Histogram */}
@@ -0,0 +1,93 @@
import { AlertTriangle, ArrowRight } from "lucide-react"
import React from "react"
import { useObservabilityData } from "../hooks/useObservabilityData"
import { useIssues } from "../hooks/useIssues"
import { getServiceName, isSpanError } from "../lib/otel-utils"
/** Full-width alert strip docked under the top bar; renders nothing when there is nothing anomalous. */
export const AnomalyRadarBar: React.FC = () => {
const { logs, traces, showErrorsOnly, setShowErrorsOnly, setActiveTab } = useObservabilityData()
const { issues } = useIssues()
const errorStats = React.useMemo(() => {
let errorCount = 0
const services = new Set<string>()
const note = (name: string) => { if (name !== "unknown-service") services.add(name) }
logs.forEach((l) =>
(l.scopeLogs ?? []).forEach((sl) =>
(sl.logRecords ?? []).forEach((r) => {
if ((r.severityNumber ?? 0) >= 17) {
errorCount++
note(getServiceName(l.resource?.attributes ?? [], r.attributes ?? []))
}
}),
),
)
traces.forEach((t) =>
(t.resourceSpans ?? []).forEach((rs) =>
(rs.scopeSpans ?? []).forEach((ss) =>
(ss.spans ?? []).forEach((s) => {
if (isSpanError(s)) {
errorCount++
note(getServiceName(rs.resource?.attributes ?? [], s.attributes ?? []))
}
}),
),
),
)
return { errorCount, services: Array.from(services) }
}, [logs, traces])
const topOpenIssue = React.useMemo(
() => issues.filter((i) => !i.status || i.status === "open").sort((a, b) => b.count - a.count)[0] ?? null,
[issues],
)
if (errorStats.errorCount === 0 && !topOpenIssue) return null
const services = errorStats.services.slice(0, 3).join(", ") + (errorStats.services.length > 3 ? ` +${errorStats.services.length - 3}` : "")
return (
<div
role="alert"
className="sticky top-[58px] z-30 flex flex-wrap items-center gap-x-4 gap-y-1.5 border-b-2 border-ds-critical bg-ds-critical-bg px-6 py-2 font-[family-name:var(--font-ui)]"
>
<span className="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.1em] text-ds-critical">
<AlertTriangle className="h-3.5 w-3.5" aria-hidden="true" />
Anomaly radar
</span>
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ds-text">
{errorStats.errorCount > 0 && (
<><strong className="font-semibold">{errorStats.errorCount} errors</strong> in the active window{services && <span className="text-ds-text-2"> · {services}</span>}</>
)}
{topOpenIssue && (
<span className="text-ds-text-2">
{errorStats.errorCount > 0 ? " · top issue " : ""}
<span className="font-medium text-ds-text">{topOpenIssue.title}</span> ×{topOpenIssue.count} ({topOpenIssue.service})
</span>
)}
</span>
<div className="flex shrink-0 items-center gap-2">
{!showErrorsOnly && (
<button
type="button"
onClick={() => setShowErrorsOnly(true)}
className="inline-flex h-7 items-center rounded-[8px] border border-ds-critical/40 px-2.5 text-[12px] font-medium text-ds-critical hover:bg-ds-critical/10"
>
Isolate errors
</button>
)}
<button
type="button"
onClick={() => setActiveTab("tab0")}
className="inline-flex h-7 items-center gap-1 rounded-[8px] bg-ds-critical px-2.5 text-[12px] font-semibold text-white hover:opacity-90"
>
Triage issues <ArrowRight className="h-3 w-3" aria-hidden="true" />
</button>
</div>
</div>
)
}
export default AnomalyRadarBar
+1 -100
View File
@@ -1,8 +1,6 @@
import { AlertTriangle, Download } from "lucide-react"
import { Download } from "lucide-react"
import React from "react";
import { useObservabilityData } from "../hooks/useObservabilityData";
import { useIssues } from "../hooks/useIssues";
import { getServiceName, isSpanError } from "../lib/otel-utils";
export const CommandAndQueryBar: React.FC = () => {
const {
@@ -21,58 +19,8 @@ export const CommandAndQueryBar: React.FC = () => {
setMinDurationMs,
timeWindow,
setTimeWindow,
setActiveTab,
} = useObservabilityData();
const { issues } = useIssues();
// Compute real error metrics from filtered signals
const errorStats = React.useMemo(() => {
let errorCount = 0;
const servicesWithErrors = new Set<string>();
logs.forEach((l) =>
(l.scopeLogs ?? []).forEach((sl) =>
(sl.logRecords ?? []).forEach((r) => {
if ((r.severityNumber ?? 0) >= 17) {
errorCount++;
const sName = getServiceName(l.resource?.attributes ?? [], r.attributes ?? []);
if (sName !== "unknown-service") {
servicesWithErrors.add(sName);
}
}
}),
),
);
traces.forEach((t) =>
(t.resourceSpans ?? []).forEach((rs) =>
(rs.scopeSpans ?? []).forEach((ss) =>
(ss.spans ?? []).forEach((s) => {
if (isSpanError(s)) {
errorCount++;
const sName = getServiceName(rs.resource?.attributes ?? [], s.attributes ?? []);
if (sName !== "unknown-service") {
servicesWithErrors.add(sName);
}
}
}),
),
),
);
return {
errorCount,
services: Array.from(servicesWithErrors),
};
}, [logs, traces]);
// Top open issue if available
const topOpenIssue = React.useMemo(() => {
const open = issues.filter((i) => !i.status || i.status === "open");
return open.sort((a, b) => b.count - a.count)[0] || null;
}, [issues]);
const totalFilteredEvents = logs.length + traces.length + metrics.length;
const exportNDJSON = () => {
@@ -100,53 +48,6 @@ export const CommandAndQueryBar: React.FC = () => {
return (
<div className="flex flex-col gap-2.5 mb-4 font-body">
{/* Real Anomaly Radar Banner */}
{(errorStats.errorCount > 0 || topOpenIssue) && (
<div className="rounded-xl border border-ds-critical/30 bg-ds-surface-2 p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-3 animate-in fade-in duration-300">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-ds-critical-bg text-ds-critical border border-ds-critical/40">
<AlertTriangle className="h-[18px] w-[18px] shrink-0" aria-hidden="true" />
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-bold text-ds-critical uppercase tracking-wider">
Anomaly Radar:
</span>
<span className="font-mono text-xs text-ds-text truncate">
{errorStats.errorCount > 0
? `${errorStats.errorCount} errors in active window (${errorStats.services.join(", ") || "various"})`
: `${topOpenIssue?.count} occurrences of "${topOpenIssue?.title}"`}
</span>
</div>
{topOpenIssue && (
<span className="text-[11px] text-ds-text-2 truncate">
Top Issue: <span className="text-ds-text font-semibold">{topOpenIssue.title}</span> (Service: {topOpenIssue.service}, ×{topOpenIssue.count})
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => setActiveTab("tab0")}
className="px-3 py-1 rounded-lg bg-ds-critical hover:bg-ds-critical text-ds-text font-mono text-xs font-bold transition-colors"
>
Triage Issues
</button>
{!showErrorsOnly && (
<button
type="button"
onClick={() => setShowErrorsOnly(true)}
className="px-2.5 py-1 rounded-lg bg-ds-fill hover:bg-ds-fill text-ds-text font-mono text-xs transition-colors"
>
Isolate Errors
</button>
)}
</div>
</div>
)}
{/* Filter and Telemetry Status Bar */}
<div className="flex flex-wrap items-center justify-between gap-2.5 p-3 rounded-xl bg-ds-bg border border-ds-line">
<div className="flex items-center gap-2 flex-wrap">
+5 -5
View File
@@ -37,7 +37,7 @@ import { NeedleTestModal } from "./NeedleTestModal"
import { FilterBar, type FilterChip } from "./FilterBar"
import type { Issue, IssueSample } from "../lib/types"
const kindMeta: Record<string, { label: string; Icon: typeof Activity }> = {
export const kindMeta: Record<string, { label: string; Icon: typeof Activity }> = {
span_error: { label: "Trace error", Icon: Activity },
log_error: { label: "Log error", Icon: ScrollText },
frontend_error: { label: "Frontend error", Icon: Globe },
@@ -98,7 +98,7 @@ const toneDot: Record<SeverityTone, string> = {
muted: "bg-ds-text-3",
}
const tonePill: Record<SeverityTone, string> = {
export const tonePill: Record<SeverityTone, string> = {
critical: "bg-ds-critical-bg text-ds-critical",
warn: "bg-ds-warn-bg text-ds-warn",
ok: "bg-ds-ok/10 text-ds-ok",
@@ -112,7 +112,7 @@ const toneLabel: Record<SeverityTone, string> = {
muted: "Ignored",
}
const toneLine: Record<SeverityTone, string> = {
export const toneLine: Record<SeverityTone, string> = {
critical: "border-l-ds-critical",
warn: "border-l-ds-warn",
ok: "border-l-ds-ok",
@@ -120,7 +120,7 @@ const toneLine: Record<SeverityTone, string> = {
}
/** The sparkline + expected-band + breach segment described by the anomaly presentation spec. */
const IssueSparkline = ({ issue }: { issue: Issue }) => {
export const IssueSparkline = ({ issue }: { issue: Issue }) => {
const { points, max, reference, breachIndex } = sparklineSeries(issue, 24)
const width = 100
const height = 100
@@ -180,7 +180,7 @@ const IssueSparkline = ({ issue }: { issue: Issue }) => {
)
}
const StatCell = ({ label, value, tone }: { label: string; value: ReactNode; tone?: SeverityTone }) => (
export const StatCell = ({ label, value, tone }: { label: string; value: ReactNode; tone?: SeverityTone }) => (
<div className="flex flex-1 flex-col gap-1">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.09em] text-ds-text-3">{label}</span>
<span
+156 -39
View File
@@ -1,23 +1,40 @@
import { useMemo, useState } from "react"
import { GitBranch, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react"
import { Bug, ChevronDown, ChevronUp, GitBranch, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react"
import { usePatterns } from "../hooks/usePatterns"
import { formatRelativeTime } from "../lib/timeUtils"
import type { Issue, SimilarIssue } from "../lib/types"
import { issueSeverityTone, issueStatusOf, type SeverityTone } from "../lib/issue-utils"
import { IssueSparkline, StatCell, kindMeta, toneLine, tonePill } from "./IssuesVisualizer"
import type { Issue, IssuePattern, SimilarIssue } from "../lib/types"
const PatternIssues = ({ pattern, issues, similar, review }: {
pattern: { issueIds: string[] }
issues: Issue[]
const toneRank: Record<SeverityTone, number> = { critical: 3, warn: 2, muted: 1, ok: 0 }
/** Folds member issues into one synthetic issue so the shared sparkline can chart the whole pattern. */
const mergeIssues = (members: Issue[]): Issue => {
const trend: Record<string, number> = {}
members.forEach((m) => Object.entries(m.trend ?? {}).forEach(([hour, n]) => { trend[hour] = (trend[hour] ?? 0) + n }))
return { ...members[0], trend }
}
const PatternCard = ({ pattern, members, similar, review }: {
pattern: IssuePattern
members: Issue[]
similar: (id: string) => Promise<SimilarIssue[]>
review: (id: string, otherId: string, decision: "same" | "different") => Promise<void>
}) => {
const [expanded, setExpanded] = useState(false)
const [selected, setSelected] = useState<string | null>(null)
const [related, setRelated] = useState<Record<string, SimilarIssue[]>>({})
const [busy, setBusy] = useState<string | null>(null)
const members = pattern.issueIds.map((id) => issues.find((issue) => issue.id === id)).filter(Boolean) as Issue[]
const toggle = async (issue: Issue) => {
if (expanded) { setExpanded(false); return }
setExpanded(true)
const merged = useMemo(() => (members.length ? mergeIssues(members) : null), [members])
const tone = members.reduce<SeverityTone>((worst, m) => (toneRank[issueSeverityTone(m)] > toneRank[worst] ? issueSeverityTone(m) : worst), "ok")
const openCount = members.filter((m) => issueStatusOf(m) === "open").length
const meta = kindMeta[pattern.kind] ?? { label: pattern.kind, Icon: Bug }
const title = [...members].sort((a, b) => b.count - a.count)[0]?.title ?? pattern.signature.split("\u0000")[0]
const select = async (issue: Issue) => {
if (selected === issue.id) { setSelected(null); return }
setSelected(issue.id)
if (!related[issue.id]) {
const suggestions = await similar(issue.id)
setRelated((prev) => ({ ...prev, [issue.id]: suggestions }))
@@ -29,48 +46,148 @@ const PatternIssues = ({ pattern, issues, similar, review }: {
setBusy(key)
try {
await review(issue.id, item.issue.id, decision)
setRelated((prev) => ({ ...prev, [issue.id]: (prev[issue.id] ?? []).map((entry) => entry.issue.id === item.issue.id ? { ...entry, decision } : entry) }))
setRelated((prev) => ({ ...prev, [issue.id]: (prev[issue.id] ?? []).map((e) => e.issue.id === item.issue.id ? { ...e, decision } : e) }))
} finally { setBusy(null) }
}
const selectedIssue = members.find((m) => m.id === selected)
return (
<div className="mt-3 space-y-2 border-t border-ds-line pt-3">
<div className="flex flex-wrap gap-2">
{members.map((issue) => <button key={issue.id} onClick={() => void toggle(issue)} className="rounded-lg border border-ds-line bg-ds-surface-2 px-2 py-1 text-left text-[10px] text-ds-text-2 hover:border-ds-accent/40">{issue.title} <span className="text-ds-text-3">×{issue.count}</span></button>)}
</div>
{expanded && members[0] && <div className="space-y-2 rounded-lg bg-ds-surface-2 p-3">
<div className="text-[10px] uppercase tracking-widest text-ds-text-3">Deterministic suggestions for {members[0].title}</div>
{(related[members[0].id] ?? []).map((item) => {
const key = `${members[0].id}:${item.issue.id}`
return <div key={item.issue.id} className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="text-ds-text-2">{item.issue.title}</span><span className="text-ds-accent">{Math.round(item.score * 100)}%</span><span className="text-ds-text-3">{item.reasons.join(" · ")}</span>
<button disabled={busy === key} onClick={() => void submitReview(members[0], item, "same")} className="ml-auto inline-flex items-center gap-1 rounded border border-ds-ok/20 px-2 py-1 text-ds-ok hover:bg-ds-ok/15"><ThumbsUp className="h-3 w-3" /> same</button>
<button disabled={busy === key} onClick={() => void submitReview(members[0], item, "different")} className="inline-flex items-center gap-1 rounded border border-ds-critical/20 px-2 py-1 text-ds-critical hover:bg-ds-critical-bg"><ThumbsDown className="h-3 w-3" /> separate</button>
{item.decision && <span className="text-ds-text-3">reviewed: {item.decision}</span>}
<article className={`flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] ${toneLine[tone]} bg-ds-surface`}>
<div className="flex w-full flex-wrap items-start gap-4 px-5 pt-[18px] pb-1">
<button type="button" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded} className="flex min-w-0 flex-1 flex-col gap-1.5 text-left">
<div className="flex flex-wrap items-center gap-2.5">
<span className={`inline-flex h-[23px] items-center rounded-[6px] px-2 text-[10.5px] font-bold uppercase tracking-wider ${tonePill[tone]}`}>
{openCount > 0 ? `${openCount} open` : "All closed"}
</span>
<span className="inline-flex h-[23px] items-center gap-1.5 rounded-[6px] border border-ds-line-strong px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-text-2">
<meta.Icon className="h-3 w-3" aria-hidden="true" />{meta.label}
</span>
<span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(pattern.lastSeen / 1e6)}</span>
</div>
})}
</div>}
</div>
<h3 className="break-all font-[family-name:var(--font-display)] text-[17px] font-bold leading-snug text-ds-text">{title}</h3>
<p className="break-all font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">service={pattern.service} · {pattern.signature.split("\u0000").join(" · ")}</p>
</button>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} pattern ${title}`}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[9px] text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text-2"
>
{expanded ? <ChevronUp className="h-4 w-4" aria-hidden="true" /> : <ChevronDown className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
{merged && <div className="px-5"><IssueSparkline issue={merged} /></div>}
<div className="flex flex-wrap gap-5 border-t border-ds-fill px-5 py-3">
<StatCell label="Exact issues" value={pattern.issueCount} />
<StatCell label="Occurrences" value={`×${pattern.occurrenceCount}`} />
<StatCell label="Open" value={openCount} tone={openCount > 0 ? tone : undefined} />
<StatCell label="Last seen" value={formatRelativeTime(pattern.lastSeen / 1e6)} />
</div>
{expanded && (
<div className="flex flex-col gap-3 border-t border-ds-line bg-ds-bg px-5 py-4 text-[12.5px]">
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">Member issues — select one to review similar issues</p>
<div className="flex flex-col gap-1.5">
{members.map((issue) => (
<button
key={issue.id}
type="button"
onClick={() => void select(issue)}
aria-pressed={selected === issue.id}
className={`flex items-center gap-3 rounded-[9px] border bg-ds-surface px-2.5 py-1.5 text-left hover:border-ds-accent/40 ${selected === issue.id ? "border-ds-accent/50" : "border-ds-line"}`}
>
<span className={`inline-flex h-[20px] shrink-0 items-center rounded-[6px] px-1.5 text-[10px] font-bold uppercase ${tonePill[issueSeverityTone(issue)]}`}>{issueStatusOf(issue)}</span>
<span className="min-w-0 flex-1 break-all text-ds-text-2">{issue.title}</span>
<span className="shrink-0 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">×{issue.count}</span>
</button>
))}
</div>
{selectedIssue && (
<div className="space-y-2 rounded-[12px] border border-ds-line bg-ds-surface-2 p-3">
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">Deterministic suggestions for {selectedIssue.title}</p>
{related[selectedIssue.id] === undefined && <p className="text-ds-text-3">Loading…</p>}
{related[selectedIssue.id]?.length === 0 && <p className="text-ds-text-3">No similar issues found.</p>}
{(related[selectedIssue.id] ?? []).map((item) => {
const key = `${selectedIssue.id}:${item.issue.id}`
return (
<div key={item.issue.id} className="flex flex-wrap items-center gap-2 text-[12px]">
<span className="text-ds-text-2">{item.issue.title}</span>
<span className="font-[family-name:var(--font-data)] text-ds-accent">{Math.round(item.score * 100)}%</span>
<span className="text-ds-text-3">{item.reasons.join(" · ")}</span>
{item.decision && <span className="text-ds-text-3">reviewed: {item.decision}</span>}
<button disabled={busy === key} onClick={() => void submitReview(selectedIssue, item, "same")} className="ml-auto inline-flex items-center gap-1 rounded-[9px] border border-ds-ok/30 px-2.5 py-1 font-medium text-ds-ok hover:bg-ds-ok/10 disabled:opacity-50"><ThumbsUp className="h-3 w-3" aria-hidden="true" /> Same</button>
<button disabled={busy === key} onClick={() => void submitReview(selectedIssue, item, "different")} className="inline-flex items-center gap-1 rounded-[9px] border border-ds-critical/30 px-2.5 py-1 font-medium text-ds-critical hover:bg-ds-critical-bg disabled:opacity-50"><ThumbsDown className="h-3 w-3" aria-hidden="true" /> Separate</button>
</div>
)
})}
</div>
)}
</div>
)}
</article>
)
}
export function PatternsVisualizer() {
const { patterns, issues, loading, error, refresh, similar, review } = usePatterns()
const [query, setQuery] = useState("")
const visible = useMemo(() => patterns.filter((pattern) => `${pattern.service} ${pattern.kind} ${pattern.signature}`.toLowerCase().includes(query.toLowerCase())), [patterns, query])
const visible = useMemo(() => {
const q = query.toLowerCase()
return patterns
.filter((p) => `${p.service} ${p.kind} ${p.signature}`.toLowerCase().includes(q))
.sort((a, b) => b.occurrenceCount - a.occurrenceCount)
}, [patterns, query])
const byId = useMemo(() => new Map(issues.map((i) => [i.id, i])), [issues])
if (loading && patterns.length === 0) return <div className="py-16 text-center text-sm text-ds-text-3">Loading deterministic patterns…</div>
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 border-b border-ds-line pb-3">
<div><div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.2em] text-ds-text-3"><GitBranch className="h-3 w-3 text-ds-accent" /> Similarity Patterns</div><div className="mt-1 text-xs text-ds-text-3">Exact issues stay separate; broader deterministic patterns are reviewable.</div></div>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter patterns…" className="ml-auto rounded-lg border border-ds-line bg-ds-surface-2 px-3 py-2 text-xs text-ds-text outline-none placeholder:text-ds-text-3 focus:border-ds-accent/40" />
<button onClick={() => void refresh()} className="inline-flex items-center gap-2 rounded-lg border border-ds-line px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-ds-text-2 hover:text-ds-text"><RefreshCw className="h-3 w-3" /> Refresh</button>
return (
<div className="flex flex-col gap-3.5">
<div className="flex flex-wrap items-center gap-3">
<h1 className="flex items-center gap-2 font-[family-name:var(--font-display)] text-[22px] font-bold tracking-[-0.015em] text-ds-text">
<GitBranch className="h-5 w-5 text-ds-accent" aria-hidden="true" /> Patterns
</h1>
<span className="inline-flex h-[22px] items-center rounded-[6px] bg-ds-accent-bg px-2 text-[11.5px] font-semibold text-ds-accent">{visible.length}</span>
<span className="text-[12.5px] text-ds-text-3">Exact issues stay separate; broader deterministic patterns are reviewable.</span>
<span className="flex-grow" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter patterns…"
className="h-9 rounded-[9px] border border-ds-line-strong bg-ds-surface px-3 text-[12px] text-ds-text outline-none placeholder:text-ds-text-3 focus:border-ds-accent/40"
/>
<button type="button" onClick={() => void refresh()} className="inline-flex h-9 items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 text-[12px] font-medium text-ds-text-2 hover:text-ds-text">
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} aria-hidden="true" /> Refresh
</button>
</div>
{error && <p className="text-[12px] text-ds-critical">Failed to load patterns: {String(error)}</p>}
{visible.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-16 text-center">
<GitBranch className="h-8 w-8 text-ds-text-3/60" aria-hidden="true" />
<p className="text-[14px] font-medium text-ds-text">{patterns.length === 0 ? "No deterministic patterns observed yet" : "Nothing matches your filter"}</p>
</div>
) : (
<div className="flex flex-col gap-3">
{visible.map((pattern) => (
<PatternCard
key={pattern.id}
pattern={pattern}
members={pattern.issueIds.map((id) => byId.get(id)).filter((i): i is Issue => Boolean(i))}
similar={similar}
review={review}
/>
))}
</div>
)}
</div>
{error && <div className="rounded-lg border border-ds-critical/20 bg-ds-critical-bg p-3 text-xs text-ds-critical">Failed to load patterns: {String(error)}</div>}
{visible.length === 0 ? <div className="py-16 text-center text-sm text-ds-text-3">No deterministic patterns observed yet.</div> : <div className="space-y-3">{visible.map((pattern) => <div key={pattern.id} className="rounded-xl border border-ds-accent/15 bg-ds-surface-2 p-4">
<div className="flex flex-wrap items-center gap-3"><span className="rounded bg-ds-accent-bg px-2 py-1 text-[10px] font-bold uppercase text-ds-accent">{pattern.service}</span><span className="text-xs text-ds-text-2">{pattern.kind}</span><span className="text-xs text-ds-text-2">{pattern.issueCount} exact issues · {pattern.occurrenceCount} occurrences</span><span className="ml-auto text-[10px] text-ds-text-3">last seen {formatRelativeTime(pattern.lastSeen / 1e6)}</span></div>
<div className="mt-3 break-all font-mono text-[10px] text-ds-text-3">{pattern.signature.split("\u0000").join(" · ")}</div>
<PatternIssues pattern={pattern} issues={issues} similar={similar} review={review} />
</div>)}</div>}
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useMemo, useState } from "react"
import { Bug, ChevronDown, ChevronUp } from "lucide-react"
import { authFetch } from "../lib/auth"
import { formatRelativeTime } from "../lib/timeUtils"
import type { RumErrorEvent, RumSession } from "../lib/rum-utils"
interface JsErrorGroup {
key: string
message: string
exceptionType?: string
count: number
sessionIds: Set<string>
lastSeen: number
/** Newest occurrence that carries a stacktrace. */
sample?: RumErrorEvent
}
const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => {
const groups = new Map<string, JsErrorGroup>()
for (const session of sessions) {
for (const err of session.errors) {
if (err.kind !== "js") continue
const key = `${err.exceptionType ?? ""}|${err.message}`
let g = groups.get(key)
if (!g) {
g = { key, message: err.message, exceptionType: err.exceptionType, count: 0, sessionIds: new Set(), lastSeen: 0 }
groups.set(key, g)
}
g.count += 1
g.sessionIds.add(session.id)
g.lastSeen = Math.max(g.lastSeen, err.timestamp)
if (err.stacktrace && (!g.sample || err.timestamp > g.sample.timestamp)) g.sample = err
}
}
return [...groups.values()].sort((a, b) => b.count - a.count || b.lastSeen - a.lastSeen)
}
/** 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 items = useMemo(
() => groups.filter((g) => g.sample?.stacktrace).map((g) => ({ key: g.key, release: g.sample!.release ?? "", stacktrace: g.sample!.stacktrace! })),
[groups],
)
const signature = items.map((i) => `${i.key}\u0000${i.release}\u0000${i.stacktrace.length}`).join("\u0001")
useEffect(() => {
if (items.length === 0) return
let cancelled = false
void (async () => {
try {
const response = await authFetch("/api/sourcemaps/resolve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items.map(({ release, stacktrace }) => ({ release, stacktrace })) }),
})
if (!response.ok) return
const body = (await response.json()) as { resolved: string[] }
if (cancelled) return
setResolved(Object.fromEntries(items.map((item, i) => [item.key, body.resolved[i] ?? item.stacktrace])))
} catch {
/* raw stacks still render */
}
})()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signature])
return resolved
}
const JsErrorCard = ({ group, resolvedStack }: { group: JsErrorGroup; resolvedStack?: string }) => {
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
return (
<article className="flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] border-l-ds-critical bg-ds-surface">
<button type="button" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded} className="flex items-start gap-4 px-5 pt-4 text-left">
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2.5">
{group.exceptionType && (
<span className="inline-flex h-[23px] items-center rounded-[6px] bg-ds-critical-bg px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-critical">{group.exceptionType}</span>
)}
{mapped && (
<span className="inline-flex h-[23px] items-center rounded-[6px] border border-ds-ok/30 px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-ok">Source-mapped</span>
)}
<span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(group.lastSeen)}</span>
</div>
<h3 className="break-all font-[family-name:var(--font-display)] text-[16px] font-bold leading-snug text-ds-text">{group.message}</h3>
{group.sample?.release && <p className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">release={group.sample.release}</p>}
</div>
<span className="mt-1 shrink-0 text-ds-text-3">{expanded ? <ChevronUp className="h-4 w-4" aria-hidden="true" /> : <ChevronDown className="h-4 w-4" aria-hidden="true" />}</span>
</button>
<div className="flex flex-wrap gap-5 border-t border-ds-fill px-5 py-3">
<div className="flex flex-col gap-1"><span className="text-[10.5px] font-semibold uppercase tracking-[0.09em] text-ds-text-3">Occurrences</span><span className="font-[family-name:var(--font-display)] text-[18px] font-bold text-ds-critical">×{group.count}</span></div>
<div className="flex flex-col gap-1"><span className="text-[10.5px] font-semibold uppercase tracking-[0.09em] text-ds-text-3">Sessions</span><span className="font-[family-name:var(--font-display)] text-[18px] font-bold text-ds-text">{group.sessionIds.size}</span></div>
</div>
{expanded && (
<div className="flex flex-col gap-2 border-t border-ds-line bg-ds-bg px-5 py-4">
<div className="flex items-center justify-between">
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{mapped && !showRaw ? "Source-mapped stacktrace" : "Stacktrace"}</p>
{mapped && (
<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 ? (
<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>
)}
</div>
)}
</article>
)
}
export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => {
const groups = useMemo(() => groupJsErrors(sessions), [sessions])
const resolved = 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>
</div>
)
}
return <div className="flex flex-col gap-3">{groups.map((g) => <JsErrorCard key={g.key} group={g} resolvedStack={resolved[g.key]} />)}</div>
}
@@ -24,6 +24,7 @@ import {
type RumTimelineEntry,
type VitalRating,
} from "../lib/rum-utils"
import { RumJsErrors } from "./RumJsErrors"
import { FilterBar, type FilterChip } from "./FilterBar"
type DeviceFilter = "all" | "mobile" | "desktop"
@@ -57,13 +58,26 @@ const StatTile = ({
value,
detail,
tone,
onClick,
active,
}: {
label: string
value: string
detail: string
tone?: "critical" | "warn" | "ok"
onClick?: () => void
active?: boolean
}) => (
<div className="flex flex-col gap-1 rounded-[12px] border border-ds-line bg-ds-surface px-4 py-3">
<div
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : undefined}
aria-pressed={onClick ? active : undefined}
onClick={onClick}
onKeyDown={onClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick() } } : undefined}
className={`flex flex-col gap-1 rounded-[12px] border bg-ds-surface px-4 py-3 ${
active ? "border-ds-critical/60" : "border-ds-line"
} ${onClick ? "cursor-pointer hover:border-ds-line-strong" : ""}`}
>
<span className="text-[10.5px] font-bold uppercase tracking-[0.09em] text-ds-text-3">{label}</span>
<span
className={`font-[family-name:var(--font-display)] text-[22px] font-bold ${
@@ -382,6 +396,7 @@ export const RumSessionsVisualizer = () => {
const [errorsOnly, setErrorsOnly] = useState(false)
const [sortMode, setSortMode] = useState<SortMode>("worst")
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [jsFocus, setJsFocus] = useState(false)
const sessions = useMemo(() => buildRumSessions(logs), [logs])
const backend = useMemo(() => backendRequestsFromTraces(allTraces), [allTraces])
@@ -489,6 +504,15 @@ export const RumSessionsVisualizer = () => {
return (
<div className="flex flex-col gap-5">
<FilterBar
scope={{
label: "View",
options: [
{ value: "sessions", label: "Sessions" },
{ value: "js-errors", label: "JS errors" },
],
value: jsFocus ? "js-errors" : "sessions",
onChange: (v) => setJsFocus(v === "js-errors"),
}}
chips={chips}
addFilterOptions={addFilterOptions}
onAddFilter={onAddFilter}
@@ -508,6 +532,8 @@ export const RumSessionsVisualizer = () => {
value={String(aggregates.totalJsErrors)}
detail={`${aggregates.distinctJsErrorMessages} distinct messages`}
tone={aggregates.totalJsErrors > 0 ? "critical" : "ok"}
onClick={() => setJsFocus((v) => !v)}
active={jsFocus}
/>
<StatTile
label="HTTP failures"
@@ -523,7 +549,9 @@ export const RumSessionsVisualizer = () => {
/>
</div>
{sorted.length === 0 ? (
{jsFocus ? (
<RumJsErrors sessions={filtered} />
) : sorted.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 rounded-[14px] border border-ds-line bg-ds-surface py-16 text-center">
<AlertOctagon className="h-6 w-6 text-ds-text-3" aria-hidden="true" />
<p className="text-[13px] text-ds-text-3">No sessions match your filters.</p>
+2
View File
@@ -63,6 +63,7 @@ export interface RumErrorEvent {
message: string
exceptionType?: string
stacktrace?: string
release?: string
httpMethod?: string
httpUrl?: string
httpStatus?: number
@@ -237,6 +238,7 @@ export function buildRumSessions(logs: LogEntry[]): RumSession[] {
message: r.exceptionMessage || r.body || r.exceptionType || "Unhandled error",
exceptionType: r.exceptionType,
stacktrace: r.stacktrace,
release: r.release,
traceId: r.traceId,
})
}