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 ( -
  • - -
  • + + {session.urls[0] ?? "no page recorded"} + +
    + + {session.device.os} · {session.device.browser} + + {inp !== undefined && ( + INP {VITAL_THRESHOLDS.INP.format(inp)} + )} + {hasIssues && {session.errorCount + session.httpFailureCount} err} +
    + {services.length > 0 && ( +
    + {services.slice(0, 4).map((u) => ( + + {u.service} ×{u.requests.length} + + ))} + {services.length > 4 && +{services.length - 4}} +
    + )} + ) } -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 }) => ( +
    + + Services used ({usage.services.length}) + + {usage.services.length === 0 ? ( +

    + {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.`} +

    + ) : ( + usage.services.map((u) => ( +
    +
    + {u.service} + + {u.requests.length} req{u.inferred > 0 ? ` · ${u.inferred} inferred` : ""} + {u.failed > 0 && · {u.failed} failed} + +
    + {u.requests.map((r) => { + const key = requestKey(r) + return ( + + ) + })} +
    + )) + )} + {usage.unlinked.length > 0 && usage.services.length > 0 && ( +

    + {usage.unlinked.length} more request{usage.unlinked.length === 1 ? "" : "s"} with no backend span (no + instrumentation on the path, not sampled, or not loaded). +

    + )} +
    +) + +/** 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 ( - - ) - } - - // 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() - 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 ( - + + ) +} + +/** 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) => ( - - ))} + {sorted.length === 0 ? ( +
    +
    + ) : ( + 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 ? ( -
    -
    - ) : ( -
      - {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 } +}