update
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 58s

This commit is contained in:
mats committed 2026-09-19 14:25:23 +02:00
1 parent 7436966689
commit 412e430cac
2 files changed
+268 -162

No files matched your search

+202 -162
View File
@@ -5,17 +5,16 @@ import {
ExternalLink,
Laptop,
Monitor,
Radio,
Smartphone,
} from "lucide-react"
import { useObservabilityData } from "../hooks/useObservabilityData"
import { formatRelativeTime } from "../lib/timeUtils"
import {
backendRequestsFromTraces,
linkRumRequest,
parseRequestCrumb,
type RumBackendLink,
} from "../lib/http-endpoint"
import { formatDuration, formatRelativeTime } from "../lib/timeUtils"
import { Segmented } from "./common/Segmented"
import { MasterDetail, type DrillApi } from "./MasterDetail"
import { TraceDetail } from "./trace/TraceDetail"
import { useProcessedTraces } from "../hooks/useProcessedTraces"
import { sessionServices, type LinkedRequest, type SessionServices } from "../lib/rum-services"
import { backendRequestsFromTraces } from "../lib/http-endpoint"
import {
VITAL_THRESHOLDS,
aggregateRum,
@@ -109,99 +108,117 @@ const SessionTimelineBar = ({ session }: { session: RumSession }) => {
)
}
const SessionRow = ({
session,
isSelected,
onSelect,
}: {
session: RumSession
isSelected: boolean
onSelect: () => void
}) => {
const SessionListItem = ({ session, usage }: { session: RumSession; usage?: SessionServices }) => {
const hasIssues = session.errorCount > 0 || session.httpFailureCount > 0
const inp = session.vitals.INP
const inpRating = inp !== undefined ? getVitalRating("INP", inp) : null
const services = usage?.services ?? []
return (
<li>
<button
type="button"
onClick={onSelect}
aria-current={isSelected ? "true" : undefined}
className={`flex w-full items-center gap-4 border-t border-ds-line px-4 py-3 text-left first:border-t-0 ${
isSelected ? "bg-ds-surface-2 border-l-[3px] border-l-ds-accent" : "border-l-[3px] border-l-transparent hover:bg-ds-fill/40"
}`}
>
<span className="flex w-[168px] shrink-0 flex-col gap-0.5">
<span className="font-[family-name:var(--font-data)] text-[12.5px] text-ds-text">{session.id}</span>
<span className="text-[11px] text-ds-text-3">
{session.urls[0] ?? "no page recorded"}
</span>
</span>
<span className="flex w-[136px] shrink-0 items-center gap-1.5 text-[12.5px] text-ds-text-2">
<DeviceIcon isMobile={session.device.isMobile} />
{session.device.os} · {session.device.browser}
</span>
<span
className={`w-[68px] shrink-0 font-[family-name:var(--font-data)] text-[12.5px] ${
inpRating ? vitalTone[inpRating] : "text-ds-text-3"
}`}
>
{inp !== undefined ? VITAL_THRESHOLDS.INP.format(inp) : "—"}
</span>
<span
className={`w-[64px] shrink-0 font-[family-name:var(--font-data)] text-[12.5px] ${
hasIssues ? "text-ds-critical" : "text-ds-text-3"
}`}
>
{session.errorCount + session.httpFailureCount}
</span>
<span className="min-w-[120px] flex-1">
<SessionTimelineBar session={session} />
</span>
<span className="w-[70px] shrink-0 text-right font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate font-[family-name:var(--font-data)] text-[12.5px] text-ds-text">{session.id}</span>
<span className="shrink-0 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
{formatRelativeTime(session.lastSeen)}
</span>
</button>
</li>
</div>
<span className="truncate text-[11.5px] text-ds-text-3">{session.urls[0] ?? "no page recorded"}</span>
<SessionTimelineBar session={session} />
<div className="flex items-center gap-2 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
<DeviceIcon isMobile={session.device.isMobile} />
<span className="truncate">{session.device.os} · {session.device.browser}</span>
<span className="flex-grow" />
{inp !== undefined && (
<span className={inpRating ? vitalTone[inpRating] : ""}>INP {VITAL_THRESHOLDS.INP.format(inp)}</span>
)}
{hasIssues && <span className="text-ds-critical">{session.errorCount + session.httpFailureCount} err</span>}
</div>
{services.length > 0 && (
<div className="flex flex-wrap gap-1">
{services.slice(0, 4).map((u) => (
<span
key={u.service}
className="rounded-[6px] border border-ds-line-strong px-1.5 py-0.5 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-2"
>
{u.service} ×{u.requests.length}
</span>
))}
{services.length > 4 && <span className="text-[10.5px] text-ds-text-3">+{services.length - 4}</span>}
</div>
)}
</div>
)
}
const SessionDetail = ({ session }: { session: RumSession | null }) => {
const { setActiveTraceId, setActiveSpanId, setActiveTab, allTraces } = useObservabilityData()
const backend = useMemo(() => backendRequestsFromTraces(allTraces), [allTraces])
const ServicesUsed = ({ usage, drill }: { usage: SessionServices; drill: DrillApi }) => (
<section className="flex flex-col gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">
Services used ({usage.services.length})
</span>
{usage.services.length === 0 ? (
<p className="text-[12px] text-ds-text-3">
{usage.requests.length === 0
? "No fetch/XHR requests recorded in this session."
: `${usage.requests.length} request${usage.requests.length === 1 ? "" : "s"} recorded, none matched a backend span.`}
</p>
) : (
usage.services.map((u) => (
<div key={u.service} className="flex flex-col gap-1 rounded-[10px] border border-ds-line p-2.5">
<div className="flex items-baseline justify-between gap-2">
<span className="font-[family-name:var(--font-data)] text-[12.5px] font-semibold text-ds-text">{u.service}</span>
<span className="font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
{u.requests.length} req{u.inferred > 0 ? ` · ${u.inferred} inferred` : ""}
{u.failed > 0 && <span className="text-ds-critical"> · {u.failed} failed</span>}
</span>
</div>
{u.requests.map((r) => {
const key = requestKey(r)
return (
<button
key={r.index}
type="button"
onClick={() => key && drill.openExtended(key)}
aria-current={key !== null && drill.extendedKey === key ? "true" : undefined}
className={`flex items-center justify-between gap-2 rounded-[6px] px-1.5 py-1 text-left hover:bg-ds-fill ${
drill.extendedKey === key ? "bg-ds-surface-2" : ""
}`}
>
<span className="truncate font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">
{r.method} {r.url}
</span>
<span className="shrink-0 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
{r.link ? formatDuration(r.link.backend.durationMs * 1e6) : ""}
</span>
</button>
)
})}
</div>
))
)}
{usage.unlinked.length > 0 && usage.services.length > 0 && (
<p className="text-[11.5px] text-ds-text-3">
{usage.unlinked.length} more request{usage.unlinked.length === 1 ? "" : "s"} with no backend span (no
instrumentation on the path, not sampled, or not loaded).
</p>
)}
</section>
)
/** Extended-pane key for a served request: "traceId|spanId". */
const requestKey = (r: LinkedRequest) => (r.link ? `${r.link.backend.traceId}|${r.link.backend.spanId}` : null)
const SessionDetail = ({
session,
usage,
drill,
}: {
session: RumSession
usage: SessionServices
drill: DrillApi
}) => {
const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
const [copied, setCopied] = useState(false)
if (!session) {
return (
<aside className="flex w-[380px] shrink-0 flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line p-8 text-center">
<Radio className="h-6 w-6 text-ds-text-3" aria-hidden="true" />
<p className="text-[13px] text-ds-text-3">Select a session to see what happened.</p>
</aside>
)
}
// Match each browser request to the backend span that served it. Falls back
// to method+path+time when the trace id doesn't survive the hop (e.g. an
// uninstrumented nginx in front of the API).
const links = new Map<number, RumBackendLink>()
session.timeline.forEach((entry, idx) => {
if (entry.category !== "fetch" && entry.category !== "xhr") return
const req = parseRequestCrumb(entry.message)
if (!req) return
const link = linkRumRequest(
{
// Breadcrumbs are stamped on completion; the request started duration earlier.
timestamp: entry.timestamp - (entry.durationMs ?? 0),
durationMs: entry.durationMs,
traceId: entry.traceId,
method: req.method,
url: req.url,
},
backend,
)
if (link) links.set(idx, link)
})
const linkByIndex = new Map(usage.requests.map((r) => [r.index, r]))
const latestTracedError = session.errors.find((e) => e.traceId)
@@ -218,7 +235,7 @@ const SessionDetail = ({ session }: { session: RumSession | null }) => {
}
return (
<aside className="flex w-[380px] shrink-0 flex-col gap-4 overflow-hidden rounded-[14px] border border-ds-line bg-ds-surface p-4">
<div className="flex flex-col gap-4 rounded-[14px] border border-ds-line bg-ds-surface p-4">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
@@ -275,8 +292,10 @@ const SessionDetail = ({ session }: { session: RumSession | null }) => {
</div>
</div>
<ServicesUsed usage={usage} drill={drill} />
<span className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">What happened</span>
<ol className="flex flex-1 flex-col gap-3 overflow-y-auto">
<ol className="flex flex-col gap-3">
{session.timeline.map((entry, idx) => (
<li key={idx} className="flex gap-3">
<span className="w-11 shrink-0 pt-0.5 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
@@ -295,22 +314,27 @@ const SessionDetail = ({ session }: { session: RumSession | null }) => {
<span className="break-all font-[family-name:var(--font-data)] text-[12px] text-ds-text-2">
{entry.message}
</span>
{links.get(idx) && (
<button
type="button"
onClick={() => viewTrace(links.get(idx)!.backend.traceId)}
className="inline-flex w-fit items-center gap-1 text-[12px] text-ds-accent"
title={
links.get(idx)!.confidence === "trace-id"
? "Linked by trace id"
: "Inferred from method, path and timing - no trace id reached the backend"
}
>
<ExternalLink className="h-3 w-3" aria-hidden="true" />
{links.get(idx)!.backend.service || "backend"} trace
{links.get(idx)!.confidence === "path+time" && " (inferred)"}
</button>
)}
{(() => {
const r = linkByIndex.get(idx)
const key = r && requestKey(r)
if (!r?.link || !key) return null
return (
<button
type="button"
onClick={() => drill.openExtended(key)}
className="inline-flex w-fit items-center gap-1 text-[12px] text-ds-accent"
title={
r.link.confidence === "trace-id"
? "Linked by trace id"
: "Inferred from method, path and timing - no trace id reached the backend"
}
>
<ExternalLink className="h-3 w-3" aria-hidden="true" />
{r.link.backend.service || "backend"} spans
{r.link.confidence === "path+time" && " (inferred)"}
</button>
)
})()}
</span>
</li>
))}
@@ -327,18 +351,45 @@ const SessionDetail = ({ session }: { session: RumSession | null }) => {
</button>
)}
{copied && <span className="text-center text-[11px] text-ds-ok">Session id copied</span>}
</aside>
</div>
)
}
/** Extended pane: the backend trace a browser request hit, with the served span preselected. */
const RequestTrace = ({ requestKey: key }: { requestKey: string }) => {
const [traceId, spanId] = key.split("|")
const { byId, loading, failed } = useProcessedTraces([traceId])
const trace = byId.get(traceId)
// Local selection so the span inspector renders inside this pane; starts on the span the browser hit.
const [picked, setPicked] = useState<string | null | undefined>(undefined)
const selectedSpanId = picked === undefined ? spanId : picked
return (
<TraceDetail
traceId={traceId}
trace={trace}
loading={loading && !failed.has(traceId)}
spanId={spanId}
label="Backend trace"
selectedSpanId={selectedSpanId}
onSelectSpan={setPicked}
/>
)
}
export const RumSessionsVisualizer = () => {
const { logs, searchQuery, setSearchQuery } = useObservabilityData()
const { logs, allTraces, searchQuery, setSearchQuery } = useObservabilityData()
const [deviceFilter, setDeviceFilter] = useState<DeviceFilter>("all")
const [errorsOnly, setErrorsOnly] = useState(false)
const [sortMode, setSortMode] = useState<SortMode>("worst")
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const sessions = useMemo(() => buildRumSessions(logs), [logs])
const backend = useMemo(() => backendRequestsFromTraces(allTraces), [allTraces])
const usageBySession = useMemo(() => {
const map = new Map<string, SessionServices>()
for (const sess of sessions) map.set(sess.id, sessionServices(sess, backend))
return map
}, [sessions, backend])
const aggregates = useMemo(() => aggregateRum(sessions), [sessions])
const filtered = useMemo(() => {
@@ -472,60 +523,49 @@ export const RumSessionsVisualizer = () => {
/>
</div>
<div className="flex min-h-0 flex-1 gap-4">
<section className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-[14px] border border-ds-line bg-ds-surface">
<div className="flex h-11 items-center gap-3 border-b border-ds-line px-4">
<h2 className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">
Sessions · worst experience first
</h2>
<span className="flex-grow" />
<div className="flex items-center gap-1 rounded-[8px] border border-ds-line bg-ds-bg p-0.5">
{(["worst", "recent"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setSortMode(mode)}
aria-pressed={sortMode === mode}
className={`rounded-[6px] px-2.5 py-1 text-[11.5px] font-semibold transition-colors ${
sortMode === mode ? "bg-ds-fill text-ds-text" : "text-ds-text-3 hover:text-ds-text-2"
}`}
>
{mode === "worst" ? "Worst experience" : "Most recent"}
</button>
))}
{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>
</div>
) : (
<MasterDetail
items={sorted}
getKey={(sess) => sess.id}
selectedKey={selected?.id ?? null}
onSelect={setSelectedSessionId}
label="RUM sessions"
storageKey="rum-sessions"
listSize={{ default: 360, min: 280, max: 560 }}
extendedSize={{ default: 520, min: 360, max: 900 }}
extendedTitle="Backend spans"
listHeader={
<div className="flex items-center justify-between gap-2">
<h2 className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">
Sessions · {sortMode === "worst" ? "worst first" : "most recent"}
</h2>
<Segmented
label="Sort sessions"
value={sortMode}
onChange={setSortMode}
options={[
{ value: "worst", label: "Worst" },
{ value: "recent", label: "Recent" },
]}
/>
</div>
</div>
<div className="flex items-center gap-4 border-b border-ds-line px-4 py-2 text-[10.5px] font-bold uppercase tracking-[0.09em] text-ds-text-3">
<span className="w-[168px]">Session</span>
<span className="w-[136px]">Device</span>
<span className="w-[68px]">INP</span>
<span className="w-[64px]">Errors</span>
<span className="min-w-[120px] flex-1">Session timeline</span>
<span className="w-[70px] text-right">Last seen</span>
</div>
{sorted.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 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>
</div>
) : (
<ul className="flex-1 overflow-y-auto">
{sorted.map((s) => (
<SessionRow
key={s.id}
session={s}
isSelected={selected?.id === s.id}
onSelect={() => setSelectedSessionId(s.id)}
/>
))}
</ul>
}
renderItem={(sess) => <SessionListItem session={sess} usage={usageBySession.get(sess.id)} />}
renderDetail={(sess, drill) => (
<SessionDetail
session={sess}
usage={usageBySession.get(sess.id) ?? { requests: [], services: [], unlinked: [] }}
drill={drill}
/>
)}
</section>
<SessionDetail session={selected} />
</div>
renderExtended={(_sess, key) => <RequestTrace key={key} requestKey={key} />}
/>
)}
</div>
)
}