From 97743ea21c3d51c716163110e1ac51b3d439be32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Sat, 19 Sep 2026 15:49:44 +0200 Subject: [PATCH] sourcemap view --- cmd/server/main.go | 1 + internal/collector/handlers.go | 25 +++ otel-ui/src/App.tsx | 4 +- otel-ui/src/components/AnomalyRadarBar.tsx | 93 +++++++++ otel-ui/src/components/CommandAndQueryBar.tsx | 101 +-------- otel-ui/src/components/IssuesVisualizer.tsx | 10 +- otel-ui/src/components/PatternsVisualizer.tsx | 195 ++++++++++++++---- otel-ui/src/components/RumJsErrors.tsx | 136 ++++++++++++ .../src/components/RumSessionsVisualizer.tsx | 32 ++- otel-ui/src/lib/rum-utils.ts | 2 + 10 files changed, 452 insertions(+), 147 deletions(-) create mode 100644 otel-ui/src/components/AnomalyRadarBar.tsx create mode 100644 otel-ui/src/components/RumJsErrors.tsx diff --git a/cmd/server/main.go b/cmd/server/main.go index 228e470..c8cd0aa 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/internal/collector/handlers.go b/internal/collector/handlers.go index 7011678..ce6b091 100644 --- a/internal/collector/handlers.go +++ b/internal/collector/handlers.go @@ -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") diff --git a/otel-ui/src/App.tsx b/otel-ui/src/App.tsx index 0abab6a..c154937 100644 --- a/otel-ui/src/App.tsx +++ b/otel-ui/src/App.tsx @@ -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 */}
+ {!isTriageOverview && }
{!isTriageOverview && ( <> - {/* Real Anomaly Radar & Filter Bar */} + {/* Filter Bar */} {/* Ingest & Incident Waveform Histogram */} diff --git a/otel-ui/src/components/AnomalyRadarBar.tsx b/otel-ui/src/components/AnomalyRadarBar.tsx new file mode 100644 index 0000000..611f3c1 --- /dev/null +++ b/otel-ui/src/components/AnomalyRadarBar.tsx @@ -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() + 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 ( +
+ + + + {errorStats.errorCount > 0 && ( + <>{errorStats.errorCount} errors in the active window{services && · {services}} + )} + {topOpenIssue && ( + + {errorStats.errorCount > 0 ? " · top issue " : ""} + {topOpenIssue.title} ×{topOpenIssue.count} ({topOpenIssue.service}) + + )} + +
+ {!showErrorsOnly && ( + + )} + +
+
+ ) +} + +export default AnomalyRadarBar diff --git a/otel-ui/src/components/CommandAndQueryBar.tsx b/otel-ui/src/components/CommandAndQueryBar.tsx index 3d4e722..0dd1e9c 100644 --- a/otel-ui/src/components/CommandAndQueryBar.tsx +++ b/otel-ui/src/components/CommandAndQueryBar.tsx @@ -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(); - - 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 (
- {/* Real Anomaly Radar Banner */} - {(errorStats.errorCount > 0 || topOpenIssue) && ( -
-
-
-
-
-
- - Anomaly Radar: - - - {errorStats.errorCount > 0 - ? `${errorStats.errorCount} errors in active window (${errorStats.services.join(", ") || "various"})` - : `${topOpenIssue?.count} occurrences of "${topOpenIssue?.title}"`} - -
- {topOpenIssue && ( - - Top Issue: {topOpenIssue.title} (Service: {topOpenIssue.service}, ×{topOpenIssue.count}) - - )} -
-
- -
- - {!showErrorsOnly && ( - - )} -
-
- )} - {/* Filter and Telemetry Status Bar */}
diff --git a/otel-ui/src/components/IssuesVisualizer.tsx b/otel-ui/src/components/IssuesVisualizer.tsx index 94abafe..79e847e 100644 --- a/otel-ui/src/components/IssuesVisualizer.tsx +++ b/otel-ui/src/components/IssuesVisualizer.tsx @@ -37,7 +37,7 @@ import { NeedleTestModal } from "./NeedleTestModal" import { FilterBar, type FilterChip } from "./FilterBar" import type { Issue, IssueSample } from "../lib/types" -const kindMeta: Record = { +export const kindMeta: Record = { 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 = { muted: "bg-ds-text-3", } -const tonePill: Record = { +export const tonePill: Record = { 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 = { muted: "Ignored", } -const toneLine: Record = { +export const toneLine: Record = { critical: "border-l-ds-critical", warn: "border-l-ds-warn", ok: "border-l-ds-ok", @@ -120,7 +120,7 @@ const toneLine: Record = { } /** 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 }) => (
{label} = { 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 = {} + 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 review: (id: string, otherId: string, decision: "same" | "different") => Promise }) => { const [expanded, setExpanded] = useState(false) + const [selected, setSelected] = useState(null) const [related, setRelated] = useState>({}) const [busy, setBusy] = useState(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((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 ( -
-
- {members.map((issue) => )} -
- {expanded && members[0] &&
-
Deterministic suggestions for {members[0].title}
- {(related[members[0].id] ?? []).map((item) => { - const key = `${members[0].id}:${item.issue.id}` - return
- {item.issue.title}{Math.round(item.score * 100)}%{item.reasons.join(" · ")} - - - {item.decision && reviewed: {item.decision}} +
+
+
} -
+

{title}

+

service={pattern.service} · {pattern.signature.split("\u0000").join(" · ")}

+ + +
+ + {merged &&
} + +
+ + + 0 ? tone : undefined} /> + +
+ + {expanded && ( +
+

Member issues — select one to review similar issues

+
+ {members.map((issue) => ( + + ))} +
+ + {selectedIssue && ( +
+

Deterministic suggestions for {selectedIssue.title}

+ {related[selectedIssue.id] === undefined &&

Loading…

} + {related[selectedIssue.id]?.length === 0 &&

No similar issues found.

} + {(related[selectedIssue.id] ?? []).map((item) => { + const key = `${selectedIssue.id}:${item.issue.id}` + return ( +
+ {item.issue.title} + {Math.round(item.score * 100)}% + {item.reasons.join(" · ")} + {item.decision && reviewed: {item.decision}} + + +
+ ) + })} +
+ )} +
+ )} + ) } 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
Loading deterministic patterns…
- return
-
-
Similarity Patterns
Exact issues stay separate; broader deterministic patterns are reviewable.
- 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" /> - + + return ( +
+
+

+

+ {visible.length} + Exact issues stay separate; broader deterministic patterns are reviewable. + + 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" + /> + +
+ + {error &&

Failed to load patterns: {String(error)}

} + + {visible.length === 0 ? ( +
+
+ ) : ( +
+ {visible.map((pattern) => ( + byId.get(id)).filter((i): i is Issue => Boolean(i))} + similar={similar} + review={review} + /> + ))} +
+ )}
- {error &&
Failed to load patterns: {String(error)}
} - {visible.length === 0 ?
No deterministic patterns observed yet.
:
{visible.map((pattern) =>
-
{pattern.service}{pattern.kind}{pattern.issueCount} exact issues · {pattern.occurrenceCount} occurrenceslast seen {formatRelativeTime(pattern.lastSeen / 1e6)}
-
{pattern.signature.split("\u0000").join(" · ")}
- -
)}
} -
+ ) } diff --git a/otel-ui/src/components/RumJsErrors.tsx b/otel-ui/src/components/RumJsErrors.tsx new file mode 100644 index 0000000..98f4b51 --- /dev/null +++ b/otel-ui/src/components/RumJsErrors.tsx @@ -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 + lastSeen: number + /** Newest occurrence that carries a stacktrace. */ + sample?: RumErrorEvent +} + +const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => { + const groups = new Map() + 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>({}) + 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 ( +
+ +
+
Occurrences×{group.count}
+
Sessions{group.sessionIds.size}
+
+ {expanded && ( +
+
+

{mapped && !showRaw ? "Source-mapped stacktrace" : "Stacktrace"}

+ {mapped && ( + + )} +
+ {stack ? ( +
{stack}
+ ) : ( +

No stacktrace was reported for this error.

+ )} + {!mapped && raw && !group.sample?.release && ( +

No release on this event, so source maps can't be applied.

+ )} +
+ )} +
+ ) +} + +export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => { + const groups = useMemo(() => groupJsErrors(sessions), [sessions]) + const resolved = useResolvedStacks(groups) + + if (groups.length === 0) { + return ( +
+
+ ) + } + return
{groups.map((g) => )}
+} diff --git a/otel-ui/src/components/RumSessionsVisualizer.tsx b/otel-ui/src/components/RumSessionsVisualizer.tsx index d709a89..2cbf99a 100644 --- a/otel-ui/src/components/RumSessionsVisualizer.tsx +++ b/otel-ui/src/components/RumSessionsVisualizer.tsx @@ -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 }) => ( -
+
{ 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" : ""}`} + > {label} { const [errorsOnly, setErrorsOnly] = useState(false) const [sortMode, setSortMode] = useState("worst") const [selectedSessionId, setSelectedSessionId] = useState(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 (
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} /> { />
- {sorted.length === 0 ? ( + {jsFocus ? ( + + ) : sorted.length === 0 ? (