more testing
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m0s

This commit is contained in:
mats committed 2026-09-19 16:44:51 +02:00
1 parent 1aa264675e
commit 2da2649a2f
11 files changed
+293 -51

No files matched your search

+16 -3
View File
@@ -301,9 +301,10 @@ RELEASE="$RELEASE" OTEL_URL="https://otel.example.com" \
``` ```
The receiver stores uploaded maps under `OTEL_SOURCEMAP_DIR` (default The receiver stores uploaded maps under `OTEL_SOURCEMAP_DIR` (default
`./sourcemaps`, `/sourcemaps` in the Kubernetes deployment). Upload maps for `./sourcemaps`, `/sourcemaps` in the Kubernetes deployment). Maps are reloaded on start, so keep the directory on persistent storage. See
each release before investigating its errors. The API never returns map `instrumentation.md` (*Source maps → Requirements checklist*) for everything a
contents or original source files. project needs for stacks to resolve. `GET /api/sourcemaps` lists loaded maps.
The API never returns map files themselves.
`build.sh` passes the release into the Docker UI build. If `OTEL_URL` is set, `build.sh` passes the release into the Docker UI build. If `OTEL_URL` is set,
it also builds the matching UI locally and uploads all generated JavaScript it also builds the matching UI locally and uploads all generated JavaScript
@@ -362,6 +363,18 @@ already points at it. The UI's Trends tab (and live issue updates) appear
automatically once the stats endpoints answer; on file-store deployments the automatically once the stats endpoints answer; on file-store deployments the
tab hides itself. SPA routes (/issues, /traces, /logs, /metrics, /trends, tab hides itself. SPA routes (/issues, /traces, /logs, /metrics, /trends,
/setup) are served client-side by wouter and survive the OIDC login redirect. /setup) are served client-side by wouter and survive the OIDC login redirect.
Selection lives in the URL too, so every step can be reloaded, stepped with
back/forward, and shared (each detail view has a link chip that copies its
permalink):
| Path | Shows |
| --- | --- |
| `/issues/<issueId>` | one issue with its traces |
| `/issues/<issueId>/trace/<traceId>` | the same, with a trace selected |
| `/patterns/<patternId>[/<issueId>]` | a pattern card open, optionally reviewing one member |
| `/traces/<traceId>[/<spanId>]` | a trace, optionally with a span selected |
| `/rum/session/<sessionId>` | a RUM session |
| `/rum/errors[/<errorId>]` | JS errors, optionally one error expanded (`errorId` is a hash of type + message) |
Currently, the history buffer size is hardcoded to 100 entries. This can be made configurable in future versions. Currently, the history buffer size is hardcoded to 100 entries. This can be made configurable in future versions.
+55 -4
View File
@@ -150,9 +150,59 @@ For example, if the deployed JavaScript is available at
`source=/assets/main-abc123.js`. The source-map filename on the build machine `source=/assets/main-abc123.js`. The source-map filename on the build machine
does not need to match the URL, but the `source` field must. does not need to match the URL, but the `source` field must.
Maps are parsed and retained server-side. Issue responses include a resolved Maps are parsed, written to `OTEL_SOURCEMAP_DIR`, and reloaded on receiver
stack when `service.release` and the generated bundle path match. Original start. Issue responses include a resolved stack when `service.release` and the
source files and map contents are never returned by the API. generated bundle path match. Uploaded maps are never returned, but when a map
embeds `sourcesContent` the RUM "JS errors" view shows a few lines of original
source around each mapped frame to signed-in users.
### Requirements checklist
A stack only resolves when every one of these holds. When it does not, check
them in this order:
1. **The build emits maps, with sources embedded.** Enable source maps in the
bundler (Vite `build.sourcemap: true`, webpack `devtool: "source-map"`).
Keep `sourcesContent` (the default) or the code view has nothing to show.
2. **The browser reports a release.** Pass the release to `OtelRUM.init({ release })`
(or `data-release` on the script tag), injected at build time. An event
with no release, or `dev`, can never match an upload.
3. **The uploaded release is byte-for-byte the same string.** Use one value for
both the bundle build and the upload (a git short SHA works).
4. **`source` matches the path in the browser stack.** Everything before
`/assets/` and any `?query` is ignored, so `source=/assets/main-abc123.js`
matches `https://app.example.com/assets/main-abc123.js`. Content-hashed
names must match exactly, and chunks each need their own upload.
5. **The map belongs to the deployed bundle.** Upload the maps from the very
build that produced the deployed files. A second local rebuild can differ
(dependencies, Node version) and silently mismatch. In Docker, copy the maps
out of the build stage (`docker build --target <stage>`, then `docker cp`)
instead of rebuilding. `build.sh` does this.
6. **Every chunk is uploaded.** Loop over all `*.js.map` files and fail the
deploy when none are found, since an empty loop otherwise succeeds silently.
7. **The receiver keeps its maps.** `OTEL_SOURCEMAP_DIR` must be on persistent
storage (a PVC in Kubernetes, not `emptyDir`). Maps written to an ephemeral
directory vanish on restart.
8. **Maps are not published with the site.** Delete `*.map` from the served
image unless public maps are intended (see below).
### Verifying a deploy
List what the receiver has loaded (requires a signed-in session or token):
```sh
curl -H "Authorization: Bearer $TOKEN" https://otel.example.com/api/sourcemaps
# [{"release":"2026.09.18-a1b2c3","source":"/assets/main-abc123.js"}, ...]
```
Then trigger a real uncaught error from application code, for example a
button that runs `setTimeout(() => { throw new Error("source-map check") })`
(this repository's RUM "JS errors" view has a *Generate test error* button),
and confirm the card shows **Source-mapped** with the original file and line.
Frame status in the stack explorer explains a miss: `no-release` (event has no
release), `no-map` (nothing uploaded for that release and file), or `unmapped`
(a map exists but has no mapping for that position).
The Docker build removes `.map` files from production images by default. For The Docker build removes `.map` files from production images by default. For
development or staging, public maps can be enabled for Chrome DevTools: development or staging, public maps can be enabled for Chrome DevTools:
@@ -170,6 +220,7 @@ production unless that is intentional.
2. Trigger a controlled backend error span or ERROR log. 2. Trigger a controlled backend error span or ERROR log.
3. Confirm both appear under Issues as separate frontend/backend issues. 3. Confirm both appear under Issues as separate frontend/backend issues.
4. Confirm the issue sample contains the expected release. 4. Confirm the issue sample contains the expected release.
5. Confirm a mapped frontend stack shows original file and line information. 5. Confirm a mapped frontend stack shows original file and line information
(see *Requirements checklist* under Source maps if it does not).
6. Confirm trace IDs connect frontend requests to backend traces where headers 6. Confirm trace IDs connect frontend requests to backend traces where headers
were propagated. were propagated.
+26 -5
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { CopyRef } from "./common/CopyRef"
import { MasterDetail } from "./MasterDetail" import { MasterDetail } from "./MasterDetail"
import { BackButton } from "./common/BackButton" import { BackButton } from "./common/BackButton"
import { SectionLabel } from "./common/DetailField" import { SectionLabel } from "./common/DetailField"
@@ -26,15 +27,18 @@ interface Row {
/** /**
* Focused view of one issue: an overview of every trace it occurred in * Focused view of one issue: an overview of every trace it occurred in
* (server-side summaries, paged), with the selected trace's waterfall and its * (server-side summaries, paged), with the selected trace's waterfall and its
* occurrences below. `initialTraceId` preselects the trace the user came from. * occurrences below. The selected trace comes from the URL (`traceId`).
*/ */
export const IssueOccurrences = ({ export const IssueOccurrences = ({
issue, issue,
initialTraceId, traceId: routeTraceId,
onTraceChange,
onBack, onBack,
}: { }: {
issue: Issue issue: Issue
initialTraceId?: string /** Trace named by the URL; the URL is the source of truth for the selection. */
traceId?: string
onTraceChange?: (traceId: string | null) => void
onBack: () => void onBack: () => void
}) => { }) => {
const { traces, untraced, total, complete, hasMore, loading, error, loadMore } = useIssueTraces(issue.id) const { traces, untraced, total, complete, hasMore, loading, error, loadMore } = useIssueTraces(issue.id)
@@ -59,7 +63,12 @@ export const IssueOccurrences = ({
) )
}, [traces, untraced, sort]) }, [traces, untraced, sort])
const [picked, setPicked] = useState<string | null>(initialTraceId ?? null) const [pickedLocal, setPickedLocal] = useState<string | null>(null)
const picked = routeTraceId ?? pickedLocal
const setPicked = (key: string) => {
setPickedLocal(key)
onTraceChange?.(rows.find((r) => r.key === key)?.traceId ?? null)
}
const selected = rows.find((r) => r.key === picked) ?? rows[0] const selected = rows.find((r) => r.key === picked) ?? rows[0]
const selectedTraceId = selected?.traceId const selectedTraceId = selected?.traceId
@@ -81,7 +90,19 @@ export const IssueOccurrences = ({
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<StatHeader <StatHeader
back={<BackButton label="All issues" onClick={onBack} />} back={<BackButton label="All issues" onClick={onBack} />}
eyebrow={`service=${issue.service}`} eyebrow={
<>
<span>service={issue.service}</span>
<CopyRef id={issue.id} path={`/issues/${encodeURIComponent(issue.id)}`} label="Copy issue link" />
{selectedTraceId && (
<CopyRef
id={selectedTraceId}
path={`/issues/${encodeURIComponent(issue.id)}/trace/${encodeURIComponent(selectedTraceId)}`}
label="Copy trace link"
/>
)}
</>
}
title={issue.title} title={issue.title}
stats={ stats={
<> <>
+20 -4
View File
@@ -32,6 +32,8 @@ import {
strongestCorrelate, strongestCorrelate,
type SeverityTone, type SeverityTone,
} from "../lib/issue-utils" } from "../lib/issue-utils"
import { useSubRoute } from "../lib/routes"
import { CopyRef } from "./common/CopyRef"
import { IssueOccurrences } from "./IssueOccurrences" import { IssueOccurrences } from "./IssueOccurrences"
import { NeedleTestModal } from "./NeedleTestModal" import { NeedleTestModal } from "./NeedleTestModal"
import { FilterBar, type FilterChip } from "./FilterBar" import { FilterBar, type FilterChip } from "./FilterBar"
@@ -343,6 +345,7 @@ const IssueCard = ({
<meta.Icon className="h-3 w-3 shrink-0" aria-hidden="true" /> <meta.Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
{meta.label} {meta.label}
</span> </span>
<CopyRef id={issue.id} path={`/issues/${encodeURIComponent(issue.id)}`} />
<span className="text-[12.5px] text-ds-text-3"> <span className="text-[12.5px] text-ds-text-3">
Detected {formatRelativeTime(issue.firstSeen / 1e6)} · last seen{" "} Detected {formatRelativeTime(issue.firstSeen / 1e6)} · last seen{" "}
{formatRelativeTime(issue.lastSeen / 1e6)} {formatRelativeTime(issue.lastSeen / 1e6)}
@@ -653,8 +656,10 @@ export const IssuesVisualizer = ({
}: IssuesVisualizerProps) => { }: IssuesVisualizerProps) => {
const { selectedServices, toggleService, resetFilters, searchQuery, setSearchQuery, uniqueServices } = const { selectedServices, toggleService, resetFilters, searchQuery, setSearchQuery, uniqueServices } =
useObservabilityData() useObservabilityData()
const [focus, setFocus] = useState<{ issueId: string; traceId?: string } | null>(null) // /issues/<issueId>[/trace/<traceId>] - the focused issue and trace live in the URL.
const focusIssue = (issue: Issue, traceId?: string) => setFocus({ issueId: issue.id, traceId }) const { segments, go } = useSubRoute()
const focus = segments[0] ? { issueId: segments[0], traceId: segments[1] === "trace" ? segments[2] : undefined } : null
const focusIssue = (issue: Issue, traceId?: string) => go(traceId ? [issue.id, "trace", traceId] : [issue.id])
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all") const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [kindFilter, setKindFilter] = useState<KindFilter>("all") const [kindFilter, setKindFilter] = useState<KindFilter>("all")
const [needleModalOpen, setNeedleModalOpen] = useState(false) const [needleModalOpen, setNeedleModalOpen] = useState(false)
@@ -819,11 +824,22 @@ export const IssuesVisualizer = ({
<IssueOccurrences <IssueOccurrences
key={focus.issueId} key={focus.issueId}
issue={focusedIssue} issue={focusedIssue}
initialTraceId={focus.traceId} traceId={focus.traceId}
onBack={() => setFocus(null)} onTraceChange={(traceId) => go(traceId ? [focus.issueId, "trace", traceId] : [focus.issueId], { replace: true })}
onBack={() => go([])}
/> />
) )
} }
if (focus) {
return (
<div className="flex flex-col items-center gap-3 py-20 text-center">
<p className="text-[14px] font-medium text-ds-text">Issue not found</p>
<p className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{focus.issueId}</p>
<p className="max-w-sm text-[12.5px] text-ds-text-3">It may have been evicted, or the link is from another environment.</p>
<button type="button" onClick={() => go([])} className="inline-flex h-9 items-center rounded-[9px] border border-ds-line-strong px-3 text-[12.5px] font-medium text-ds-text-2 hover:text-ds-text">All issues</button>
</div>
)
}
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
+41 -16
View File
@@ -1,5 +1,7 @@
import { useMemo, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { Bug, ChevronDown, ChevronUp, GitBranch, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react" import { Bug, ChevronDown, ChevronUp, GitBranch, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react"
import { useSubRoute } from "../lib/routes"
import { CopyRef } from "./common/CopyRef"
import { usePatterns } from "../hooks/usePatterns" import { usePatterns } from "../hooks/usePatterns"
import { formatRelativeTime } from "../lib/timeUtils" import { formatRelativeTime } from "../lib/timeUtils"
import { issueSeverityTone, issueStatusOf, type SeverityTone } from "../lib/issue-utils" import { issueSeverityTone, issueStatusOf, type SeverityTone } from "../lib/issue-utils"
@@ -15,14 +17,18 @@ const mergeIssues = (members: Issue[]): Issue => {
return { ...members[0], trend } return { ...members[0], trend }
} }
const PatternCard = ({ pattern, members, similar, review }: { const PatternCard = ({ pattern, members, similar, review, expanded, selected, onExpand, onSelect }: {
pattern: IssuePattern pattern: IssuePattern
members: Issue[] members: Issue[]
expanded: boolean
/** Member issue whose similar-issue suggestions are open (from the URL). */
selected: string | null
onExpand: (open: boolean) => void
onSelect: (issueId: string | null) => void
similar: (id: string) => Promise<SimilarIssue[]> similar: (id: string) => Promise<SimilarIssue[]>
review: (id: string, otherId: string, decision: "same" | "different") => Promise<void> review: (id: string, otherId: string, decision: "same" | "different") => Promise<void>
}) => { }) => {
const [expanded, setExpanded] = useState(false) const card = useRef<HTMLElement>(null)
const [selected, setSelected] = useState<string | null>(null)
const [related, setRelated] = useState<Record<string, SimilarIssue[]>>({}) const [related, setRelated] = useState<Record<string, SimilarIssue[]>>({})
const [busy, setBusy] = useState<string | null>(null) const [busy, setBusy] = useState<string | null>(null)
@@ -32,14 +38,20 @@ const PatternCard = ({ pattern, members, similar, review }: {
const meta = kindMeta[pattern.kind] ?? { label: pattern.kind, Icon: Bug } 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 title = [...members].sort((a, b) => b.count - a.count)[0]?.title ?? pattern.signature.split("\u0000")[0]
const select = async (issue: Issue) => { // A deep link to /patterns/<id> scrolls its card into view once.
if (selected === issue.id) { setSelected(null); return } useEffect(() => {
setSelected(issue.id) if (expanded) card.current?.scrollIntoView({ block: "start" })
if (!related[issue.id]) { // eslint-disable-next-line react-hooks/exhaustive-deps -- only on first render of a linked card
const suggestions = await similar(issue.id) }, [])
setRelated((prev) => ({ ...prev, [issue.id]: suggestions }))
} // Suggestions load whenever the URL selects a member issue.
} useEffect(() => {
if (!selected || related[selected]) return
void similar(selected)
.then((suggestions) => setRelated((prev) => ({ ...prev, [selected]: suggestions })))
.catch(() => setRelated((prev) => ({ ...prev, [selected]: [] })))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected])
const submitReview = async (issue: Issue, item: SimilarIssue, decision: "same" | "different") => { const submitReview = async (issue: Issue, item: SimilarIssue, decision: "same" | "different") => {
const key = `${issue.id}:${item.issue.id}` const key = `${issue.id}:${item.issue.id}`
@@ -53,9 +65,9 @@ const PatternCard = ({ pattern, members, similar, review }: {
const selectedIssue = members.find((m) => m.id === selected) const selectedIssue = members.find((m) => m.id === selected)
return ( return (
<article className={`flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] ${toneLine[tone]} bg-ds-surface`}> <article ref={card} 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"> <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"> <button type="button" onClick={() => onExpand(!expanded)} 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"> <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]}`}> <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"} {openCount > 0 ? `${openCount} open` : "All closed"}
@@ -63,6 +75,7 @@ const PatternCard = ({ pattern, members, similar, review }: {
<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"> <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} <meta.Icon className="h-3 w-3" aria-hidden="true" />{meta.label}
</span> </span>
<CopyRef id={pattern.id} path={`/patterns/${encodeURIComponent(pattern.id)}`} label="Copy pattern link" />
<span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(pattern.lastSeen / 1e6)}</span> <span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(pattern.lastSeen / 1e6)}</span>
</div> </div>
<h3 className="break-all font-[family-name:var(--font-display)] text-[17px] font-bold leading-snug text-ds-text">{title}</h3> <h3 className="break-all font-[family-name:var(--font-display)] text-[17px] font-bold leading-snug text-ds-text">{title}</h3>
@@ -70,7 +83,7 @@ const PatternCard = ({ pattern, members, similar, review }: {
</button> </button>
<button <button
type="button" type="button"
onClick={() => setExpanded((v) => !v)} onClick={() => onExpand(!expanded)}
aria-expanded={expanded} aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} pattern ${title}`} 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" 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"
@@ -96,7 +109,7 @@ const PatternCard = ({ pattern, members, similar, review }: {
<button <button
key={issue.id} key={issue.id}
type="button" type="button"
onClick={() => void select(issue)} onClick={() => onSelect(selected === issue.id ? null : issue.id)}
aria-pressed={selected === issue.id} 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"}`} 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"}`}
> >
@@ -135,6 +148,8 @@ const PatternCard = ({ pattern, members, similar, review }: {
export function PatternsVisualizer() { export function PatternsVisualizer() {
const { patterns, issues, loading, error, refresh, similar, review } = usePatterns() const { patterns, issues, loading, error, refresh, similar, review } = usePatterns()
// /patterns/<patternId>[/<issueId>]: which card is open and which member is reviewed.
const { segments, go } = useSubRoute()
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const visible = useMemo(() => { const visible = useMemo(() => {
@@ -170,6 +185,12 @@ export function PatternsVisualizer() {
{error && <p className="text-[12px] text-ds-critical">Failed to load patterns: {String(error)}</p>} {error && <p className="text-[12px] text-ds-critical">Failed to load patterns: {String(error)}</p>}
{segments[0] && !patterns.some((p) => p.id === segments[0]) && patterns.length > 0 && (
<p className="text-[12px] text-ds-warn">
Pattern <span className="font-[family-name:var(--font-data)]">{segments[0]}</span> not found (it may have been merged or evicted).
</p>
)}
{visible.length === 0 ? ( {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"> <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" /> <GitBranch className="h-8 w-8 text-ds-text-3/60" aria-hidden="true" />
@@ -184,6 +205,10 @@ export function PatternsVisualizer() {
members={pattern.issueIds.map((id) => byId.get(id)).filter((i): i is Issue => Boolean(i))} members={pattern.issueIds.map((id) => byId.get(id)).filter((i): i is Issue => Boolean(i))}
similar={similar} similar={similar}
review={review} review={review}
expanded={segments[0] === pattern.id}
selected={segments[0] === pattern.id ? (segments[1] ?? null) : null}
onExpand={(open) => go(open ? [pattern.id] : [])}
onSelect={(issueId) => go(issueId ? [pattern.id, issueId] : [pattern.id])}
/> />
))} ))}
</div> </div>
+22 -8
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { Bug, ChevronDown, ChevronUp } from "lucide-react" import { Bug, ChevronDown, ChevronUp } from "lucide-react"
import { CopyRef } from "./common/CopyRef"
import { triggerTestError } from "../lib/test-error" import { triggerTestError } from "../lib/test-error"
import { authFetch } from "../lib/auth" import { authFetch } from "../lib/auth"
import { formatRelativeTime } from "../lib/timeUtils" import { formatRelativeTime } from "../lib/timeUtils"
@@ -8,6 +9,8 @@ import type { RumErrorEvent, RumSession } from "../lib/rum-utils"
interface JsErrorGroup { interface JsErrorGroup {
key: string key: string
/** Stable short id derived from the error's type and message, used in URLs. */
id: string
message: string message: string
exceptionType?: string exceptionType?: string
count: number count: number
@@ -17,6 +20,12 @@ interface JsErrorGroup {
sample?: RumErrorEvent sample?: RumErrorEvent
} }
const hashId = (text: string): string => {
let h = 0x811c9dc5
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
return (h >>> 0).toString(16).padStart(8, "0")
}
const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => { const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => {
const groups = new Map<string, JsErrorGroup>() const groups = new Map<string, JsErrorGroup>()
for (const session of sessions) { for (const session of sessions) {
@@ -25,7 +34,7 @@ const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => {
const key = `${err.exceptionType ?? ""}|${err.message}` const key = `${err.exceptionType ?? ""}|${err.message}`
let g = groups.get(key) let g = groups.get(key)
if (!g) { if (!g) {
g = { key, message: err.message, exceptionType: err.exceptionType, count: 0, sessionIds: new Set(), lastSeen: 0 } g = { key, id: hashId(key), message: err.message, exceptionType: err.exceptionType, count: 0, sessionIds: new Set(), lastSeen: 0 }
groups.set(key, g) groups.set(key, g)
} }
g.count += 1 g.count += 1
@@ -73,8 +82,12 @@ const useResolvedStacks = (groups: JsErrorGroup[]) => {
return { resolved, frames } return { resolved, frames }
} }
const JsErrorCard = ({ group, resolvedStack, frames }: { group: JsErrorGroup; resolvedStack?: string; frames: StackFrame[] }) => { const JsErrorCard = ({ group, resolvedStack, frames, expanded, onToggle }: { group: JsErrorGroup; resolvedStack?: string; frames: StackFrame[]; expanded: boolean; onToggle: () => void }) => {
const [expanded, setExpanded] = useState(false) const card = useRef<HTMLElement>(null)
useEffect(() => {
if (expanded) card.current?.scrollIntoView({ block: "start" })
// eslint-disable-next-line react-hooks/exhaustive-deps -- only when a link opens this card on first render
}, [])
const [showRaw, setShowRaw] = useState(false) const [showRaw, setShowRaw] = useState(false)
const raw = group.sample?.stacktrace const raw = group.sample?.stacktrace
const mapped = Boolean(raw && resolvedStack && resolvedStack !== raw) const mapped = Boolean(raw && resolvedStack && resolvedStack !== raw)
@@ -82,8 +95,8 @@ const JsErrorCard = ({ group, resolvedStack, frames }: { group: JsErrorGroup; re
const explorable = frames.length > 0 && frames.some((f) => f.status === "mapped") const explorable = frames.length > 0 && frames.some((f) => f.status === "mapped")
return ( return (
<article className="flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] border-l-ds-critical bg-ds-surface"> <article ref={card} 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"> <button type="button" onClick={onToggle} 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 min-w-0 flex-1 flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
{group.exceptionType && ( {group.exceptionType && (
@@ -92,6 +105,7 @@ const JsErrorCard = ({ group, resolvedStack, frames }: { group: JsErrorGroup; re
{mapped && ( {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="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>
)} )}
<CopyRef id={group.id} path={`/rum/errors/${group.id}`} label="Copy error link" />
<span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(group.lastSeen)}</span> <span className="text-[12.5px] text-ds-text-3">last seen {formatRelativeTime(group.lastSeen)}</span>
</div> </div>
<h3 className="break-all font-[family-name:var(--font-display)] text-[16px] font-bold leading-snug text-ds-text">{group.message}</h3> <h3 className="break-all font-[family-name:var(--font-display)] text-[16px] font-bold leading-snug text-ds-text">{group.message}</h3>
@@ -142,7 +156,7 @@ const TestErrorButton = () => (
</button> </button>
) )
export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => { export const RumJsErrors = ({ sessions, openId, onOpen }: { sessions: RumSession[]; openId: string | null; onOpen: (id: string | null) => void }) => {
const groups = useMemo(() => groupJsErrors(sessions), [sessions]) const groups = useMemo(() => groupJsErrors(sessions), [sessions])
const { resolved, frames } = useResolvedStacks(groups) const { resolved, frames } = useResolvedStacks(groups)
@@ -155,5 +169,5 @@ export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => {
</div> </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> 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] ?? []} expanded={openId === g.id} onToggle={() => onOpen(openId === g.id ? null : g.id)} />)}</div>
} }
@@ -24,6 +24,8 @@ import {
type RumTimelineEntry, type RumTimelineEntry,
type VitalRating, type VitalRating,
} from "../lib/rum-utils" } from "../lib/rum-utils"
import { useSubRoute } from "../lib/routes"
import { CopyRef } from "./common/CopyRef"
import { RumJsErrors } from "./RumJsErrors" import { RumJsErrors } from "./RumJsErrors"
import { FilterBar, type FilterChip } from "./FilterBar" import { FilterBar, type FilterChip } from "./FilterBar"
@@ -266,6 +268,7 @@ const SessionDetail = ({
{session.environment ? ` · ${session.environment}` : ""} {session.environment ? ` · ${session.environment}` : ""}
</span> </span>
</div> </div>
<CopyRef id={session.id} path={`/rum/session/${encodeURIComponent(session.id)}`} label="Copy session link" />
<button <button
type="button" type="button"
onClick={copySessionId} onClick={copySessionId}
@@ -395,8 +398,12 @@ export const RumSessionsVisualizer = () => {
const [deviceFilter, setDeviceFilter] = useState<DeviceFilter>("all") const [deviceFilter, setDeviceFilter] = useState<DeviceFilter>("all")
const [errorsOnly, setErrorsOnly] = useState(false) const [errorsOnly, setErrorsOnly] = useState(false)
const [sortMode, setSortMode] = useState<SortMode>("worst") const [sortMode, setSortMode] = useState<SortMode>("worst")
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null) // /rum, /rum/session/<sessionId>, /rum/errors[/<errorId>]
const [jsFocus, setJsFocus] = useState(false) const { segments, go } = useSubRoute()
const jsFocus = segments[0] === "errors"
const selectedSessionId = segments[0] === "session" ? (segments[1] ?? null) : null
const setJsFocus = (on: boolean) => go(on ? ["errors"] : [])
const setSelectedSessionId = (id: string) => go(["session", id])
const sessions = useMemo(() => buildRumSessions(logs), [logs]) const sessions = useMemo(() => buildRumSessions(logs), [logs])
const backend = useMemo(() => backendRequestsFromTraces(allTraces), [allTraces]) const backend = useMemo(() => backendRequestsFromTraces(allTraces), [allTraces])
@@ -532,7 +539,7 @@ export const RumSessionsVisualizer = () => {
value={String(aggregates.totalJsErrors)} value={String(aggregates.totalJsErrors)}
detail={`${aggregates.distinctJsErrorMessages} distinct messages`} detail={`${aggregates.distinctJsErrorMessages} distinct messages`}
tone={aggregates.totalJsErrors > 0 ? "critical" : "ok"} tone={aggregates.totalJsErrors > 0 ? "critical" : "ok"}
onClick={() => setJsFocus((v) => !v)} onClick={() => setJsFocus(!jsFocus)}
active={jsFocus} active={jsFocus}
/> />
<StatTile <StatTile
@@ -550,7 +557,7 @@ export const RumSessionsVisualizer = () => {
</div> </div>
{jsFocus ? ( {jsFocus ? (
<RumJsErrors sessions={filtered} /> <RumJsErrors sessions={filtered} openId={segments[1] ?? null} onOpen={(id) => go(id ? ["errors", id] : ["errors"])} />
) : sorted.length === 0 ? ( ) : 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"> <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" /> <AlertOctagon className="h-6 w-6 text-ds-text-3" aria-hidden="true" />
+33
View File
@@ -0,0 +1,33 @@
import { useState } from "react"
import { Check, Link2 } from "lucide-react"
import { shortId } from "../../lib/routes"
/**
* Shows a short id and copies the full permalink (origin + `path`) on click,
* so any issue, pattern, session or error can be referenced in a message.
* The full id is in the tooltip and in the copied URL.
*/
export const CopyRef = ({ id, path, label = "Copy link" }: { id: string; path: string; label?: string }) => {
const [copied, setCopied] = useState(false)
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void navigator.clipboard
.writeText(window.location.origin + path)
.then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1200)
})
.catch(() => undefined)
}}
title={`${label}: ${id}`}
aria-label={`${label} for ${id}`}
className="inline-flex h-[23px] shrink-0 items-center gap-1 rounded-[6px] border border-ds-line px-1.5 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-3 hover:border-ds-line-strong hover:text-ds-text-2"
>
{copied ? <Check className="h-3 w-3 text-ds-ok" aria-hidden="true" /> : <Link2 className="h-3 w-3" aria-hidden="true" />}
{copied ? "copied" : shortId(id)}
</button>
)
}
+2 -2
View File
@@ -16,7 +16,7 @@ export const StatHeader = ({
back, back,
stats, stats,
}: { }: {
eyebrow?: string eyebrow?: React.ReactNode
title: string title: string
back?: React.ReactNode back?: React.ReactNode
stats: React.ReactNode stats: React.ReactNode
@@ -24,7 +24,7 @@ export const StatHeader = ({
<div className="flex flex-wrap items-end gap-x-7 gap-y-3"> <div className="flex flex-wrap items-end gap-x-7 gap-y-3">
{back} {back}
<div className="min-w-0 flex-1 basis-[280px]"> <div className="min-w-0 flex-1 basis-[280px]">
{eyebrow && <p className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{eyebrow}</p>} {eyebrow && <div className="flex flex-wrap items-center gap-2 font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{eyebrow}</div>}
<h2 className="break-all font-[family-name:var(--font-display)] text-[22px] font-bold leading-tight text-ds-text"> <h2 className="break-all font-[family-name:var(--font-display)] text-[22px] font-bold leading-tight text-ds-text">
{title} {title}
</h2> </h2>
@@ -2,7 +2,7 @@
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from "react" import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from "react"
import { useLocation } from "wouter" import { useLocation } from "wouter"
import { tabForPath, pathForTab } from "../lib/routes" import { tabForPath, pathForTab, splitPath, joinPath } from "../lib/routes"
import type { import type {
MetricData, MetricData,
OtelHistoryMessage, OtelHistoryMessage,
@@ -150,11 +150,31 @@ export function ObservabilityProvider({
const setActiveTabWithRoute = useCallback( const setActiveTabWithRoute = useCallback(
(tab: string) => { (tab: string) => {
setActiveTab(tab) setActiveTab(tab)
// Already inside this tab (possibly on a deep link): keep the sub-route.
if (tabForPath(window.location.pathname) === tab) return
const path = pathForTab(tab) const path = pathForTab(tab)
if (path && path !== window.location.pathname) navigate(path) if (path) navigate(path)
}, },
[navigate], [navigate],
) )
// Trace selection <-> /traces/<traceId>[/<spanId>]. The URL is adopted when
// it names a trace; a bare /traces never clears the selection, because other
// views set the trace first and switch tabs second.
useEffect(() => {
const { base, rest } = splitPath(location)
if (base !== "/traces" || !rest[0]) return
setActiveTraceId((prev) => (prev === rest[0] ? prev : rest[0]))
setActiveSpanId((prev) => (prev === (rest[1] ?? null) ? prev : (rest[1] ?? null)))
// eslint-disable-next-line react-hooks/exhaustive-deps -- only follow URL changes
}, [location])
useEffect(() => {
if (activeTab !== "tab1" || !activeTraceId) return
const target = joinPath("/traces", activeSpanId ? [activeTraceId, activeSpanId] : [activeTraceId])
if (target !== window.location.pathname) navigate(target)
// eslint-disable-next-line react-hooks/exhaustive-deps -- location is read imperatively
}, [activeTab, activeTraceId, activeSpanId, navigate])
const [filtersExpanded, setFiltersExpanded] = useState<boolean>(false) const [filtersExpanded, setFiltersExpanded] = useState<boolean>(false)
// Trace ids we already have spans for, so selecting a trace that fell // Trace ids we already have spans for, so selecting a trace that fell
// outside the initial history window (or arrived before this session // outside the initial history window (or arrived before this session
+45 -3
View File
@@ -1,3 +1,6 @@
import { useCallback, useMemo } from "react"
import { useLocation } from "wouter"
// Route ↔ tab mapping. Tabs live in the URL (wouter) so deep links survive // Route ↔ tab mapping. Tabs live in the URL (wouter) so deep links survive
// the OIDC login redirect at the ingress: users.k6n.net sends you back to // the OIDC login redirect at the ingress: users.k6n.net sends you back to
// exactly the view you requested, and URLs are shareable/bookmarkable. // exactly the view you requested, and URLs are shareable/bookmarkable.
@@ -12,14 +15,28 @@ export const TAB_PATHS: Record<string, string> = {
"/setup": "tab4", "/setup": "tab4",
"/trends": "tab5", "/trends": "tab5",
"/search": "tab6", "/search": "tab6",
"/frontend": "tab7",
"/rum": "tab7", "/rum": "tab7",
"/frontend": "tab7",
} }
/** Tab value for a location pathname, or null when the path isn't a tab. */ /** Splits "/issues/abc/trace/def" into base "/issues" and rest ["abc","trace","def"]. */
export function splitPath(path: string): { base: string; rest: string[] } {
const parts = path.split("/").filter(Boolean)
return { base: "/" + (parts[0] ?? ""), rest: parts.slice(1).map((p) => decodeURIComponent(p)) }
}
/** Builds a path below a tab base, e.g. joinPath("/issues", ["abc", "trace", "def"]). */
export function joinPath(base: string, rest: string[]): string {
return [base, ...rest.map((p) => encodeURIComponent(p))].join("/")
}
/**
* Tab value for a location pathname, or null when the path isn't a tab.
* Deeper paths ("/issues/<id>/trace/<id>") belong to their first segment's tab.
*/
export function tabForPath(path: string): string | null { export function tabForPath(path: string): string | null {
return TAB_PATHS[path] ?? null return TAB_PATHS[splitPath(path).base] ?? null
} }
/** Canonical path for a tab value, or null when the tab has no route. */ /** Canonical path for a tab value, or null when the tab has no route. */
@@ -29,3 +46,28 @@ export function pathForTab(tab: string): string | null {
} }
return null return null
} }
/**
* Sub-route below the current tab: `segments` are the path parts after the
* tab base and `go` rewrites them, so selection state (which issue, which
* trace, ...) lives in the URL and survives reloads, back/forward and sharing.
*/
export function useSubRoute(): {
segments: string[]
go: (segments: string[], options?: { replace?: boolean }) => void
} {
const [location, navigate] = useLocation()
const { base, rest } = splitPath(location)
const key = rest.join("/")
// Stable identity per URL so effects can depend on `segments` directly.
// eslint-disable-next-line react-hooks/exhaustive-deps
const segments = useMemo(() => rest, [key])
const go = useCallback(
(next: string[], options?: { replace?: boolean }) => navigate(joinPath(base, next), options),
[base, navigate],
)
return { segments, go }
}
/** Short reference for showing an id to humans (full id stays in the URL). */
export const shortId = (id: string) => (id.length > 10 ? id.slice(0, 8) : id)