diff --git a/otel-ui/src/components/RumSessionsVisualizer.tsx b/otel-ui/src/components/RumSessionsVisualizer.tsx
index fa7cfb2..d709a89 100644
--- a/otel-ui/src/components/RumSessionsVisualizer.tsx
+++ b/otel-ui/src/components/RumSessionsVisualizer.tsx
@@ -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 (
-
-
)}
{copied && Session id copied}
-
+
+ )
+}
+
+/** 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(undefined)
+ const selectedSpanId = picked === undefined ? spanId : picked
+ return (
+
)
}
export const RumSessionsVisualizer = () => {
- const { logs, searchQuery, setSearchQuery } = useObservabilityData()
+ const { logs, allTraces, searchQuery, setSearchQuery } = useObservabilityData()
const [deviceFilter, setDeviceFilter] = useState("all")
const [errorsOnly, setErrorsOnly] = useState(false)
const [sortMode, setSortMode] = useState("worst")
const [selectedSessionId, setSelectedSessionId] = useState(null)
const sessions = useMemo(() => buildRumSessions(logs), [logs])
+ const backend = useMemo(() => backendRequestsFromTraces(allTraces), [allTraces])
+ const usageBySession = useMemo(() => {
+ const map = new Map()
+ 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 = () => {
/>
-
-
-
-
- Sessions · worst experience first
-
-
-
- {(["worst", "recent"] as const).map((mode) => (
-
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"}
-
- ))}
+ {sorted.length === 0 ? (
+
+
+
No sessions match your filters.
+
+ ) : (
+
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={
+
+
+ Sessions · {sortMode === "worst" ? "worst first" : "most recent"}
+
+
-
-
-
- Session
- Device
- INP
- Errors
- Session timeline
- Last seen
-
-
- {sorted.length === 0 ? (
-
-
-
No sessions match your filters.
-
- ) : (
-
- {sorted.map((s) => (
- setSelectedSessionId(s.id)}
- />
- ))}
-
+ }
+ renderItem={(sess) =>
}
+ renderDetail={(sess, drill) => (
+
)}
-
-
-
-
+ renderExtended={(_sess, key) => }
+ />
+ )}
)
}
diff --git a/otel-ui/src/lib/rum-services.ts b/otel-ui/src/lib/rum-services.ts
new file mode 100644
index 0000000..adb33a1
--- /dev/null
+++ b/otel-ui/src/lib/rum-services.ts
@@ -0,0 +1,66 @@
+import { linkRumRequest, parseRequestCrumb, type BackendRequest, type RumBackendLink } from "./http-endpoint"
+import type { RumSession, RumTimelineEntry } from "./rum-utils"
+
+/** One browser request from a session, with the backend span that served it (if any). */
+export interface LinkedRequest {
+ /** Index into session.timeline. */
+ index: number
+ entry: RumTimelineEntry
+ method: string
+ url: string
+ link: RumBackendLink | null
+}
+
+export interface ServiceUsage {
+ service: string
+ requests: LinkedRequest[]
+ /** Requests linked only by method+path+time, not by trace id. */
+ inferred: number
+ failed: number
+}
+
+export interface SessionServices {
+ requests: LinkedRequest[]
+ services: ServiceUsage[]
+ /** Browser requests that never matched a backend span (uninstrumented hop, sampled out, not loaded). */
+ unlinked: LinkedRequest[]
+}
+
+/** Matches every fetch/xhr in the session timeline to a backend span, then groups by serving service. */
+export function sessionServices(session: RumSession, backend: BackendRequest[]): SessionServices {
+ const requests: LinkedRequest[] = []
+ session.timeline.forEach((entry, index) => {
+ 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,
+ )
+ requests.push({ index, entry, method: req.method, url: req.url, link })
+ })
+
+ const byService = new Map()
+ const unlinked: LinkedRequest[] = []
+ for (const r of requests) {
+ if (!r.link) {
+ unlinked.push(r)
+ continue
+ }
+ const name = r.link.backend.service || "unknown"
+ const usage = byService.get(name) ?? { service: name, requests: [], inferred: 0, failed: 0 }
+ usage.requests.push(r)
+ if (r.link.confidence === "path+time") usage.inferred += 1
+ if (r.entry.severe) usage.failed += 1
+ byService.set(name, usage)
+ }
+ const services = [...byService.values()].sort((a, b) => b.requests.length - a.requests.length)
+ return { requests, services, unlinked }
+}