update
This commit is contained in:
1 parent
7436966689
commit
412e430cac
2 files changed
+268
-162
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user