+ {/* Top Banner & KPI summary */}
+
+
+
+
+
+
+
+
+
+ Frontend & Real User Monitoring (RUM)
+
+
+ otel-rum.js
+
+
+
+ Real user performance, Web Vitals (LCP/INP/CLS), browser exceptions, and breadcrumbs
+ correlated with backend traces.
+
+
+
+
+
+
+
+ {simulating ? "Ingesting..." : "Simulate RUM Telemetry"}
+
+ setActiveTab("tab4")}
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-white/10 bg-slate-900 hover:bg-slate-800 text-slate-300 font-mono text-xs font-semibold transition-all shadow-sm"
+ >
+
+ Snippet & Setup
+
+
+
+
+ {/* Real KPI Metrics Cards */}
+
+
+
+
+ Total Signals
+
+
+ {frontendEvents.length}
+
+
Live frontend telemetry
+
+
+
+
+
+ Web Vitals
+
+
+ {totalVitals}
+
+
LCP / INP / CLS / FCP / TTFB
+
+
+
+
+
+ Browser Crashes
+
+
0 ? "text-rose-400" : "text-white"
+ }`}
+ >
+ {totalErrors}
+
+
Uncaught exceptions
+
+
+
+
+
+ Network Failures
+
+
0 ? "text-amber-400" : "text-white"
+ }`}
+ >
+ {totalHttpFailures}
+
+
Failed fetch / XHR
+
+
+
+
+
+ Sessions
+
+
+ {sessions.length}
+
+
Unique client tabs
+
+
+
+
+ {/* Core Web Vitals Dashboard */}
+
+
+
+
+
+ Core Web Vitals Assessment (Google Performance Standards)
+
+
+
+ p75 evaluation metric
+
+
+
+
+ {(["LCP", "INP", "CLS", "FCP", "TTFB"] as const).map((key) => {
+ const threshold = VITAL_THRESHOLDS[key];
+ const data = vitalsMetrics[key];
+ const hasData = Boolean(data && data.values.length > 0);
+ const value = hasData ? data.p75 : 0;
+ const rating = hasData ? getVitalRating(key, value) : "good";
+
+ const ratingColors = {
+ good: "text-emerald-400 border-emerald-500/30 bg-emerald-500/10",
+ "needs-improvement": "text-amber-400 border-amber-500/30 bg-amber-500/10",
+ poor: "text-rose-400 border-rose-500/30 bg-rose-500/10",
+ }[rating];
+
+ const ratingBadge = {
+ good: "bg-emerald-500/20 text-emerald-300",
+ "needs-improvement": "bg-amber-500/20 text-amber-300",
+ poor: "bg-rose-500/20 text-rose-300",
+ }[rating];
+
+ const ratingLabel = {
+ good: "Good",
+ "needs-improvement": "Needs Improvement",
+ poor: "Poor",
+ }[rating];
+
+ return (
+
{
+ setActiveSubTab("vitals");
+ setSelectedVitalFilter(key);
+ }}
+ className={`cursor-pointer rounded-xl border p-4 transition-all hover:scale-[1.02] ${
+ hasData
+ ? `border-white/10 bg-slate-950/40 hover:border-white/20 ${ratingColors}`
+ : "border-white/5 bg-slate-950/20 opacity-70"
+ }`}
+ >
+
+ {key}
+ {hasData ? (
+
+ {ratingLabel}
+
+ ) : (
+ No samples
+ )}
+
+
+
+
+ {hasData ? threshold.format(value) : "—"}
+
+ {hasData && (
+
+ ({data.values.length} {data.values.length === 1 ? "sample" : "samples"})
+
+ )}
+
+
+
+ {threshold.description}
+
+
+
+ Target: ≤ {threshold.format(threshold.good)}
+ {hasData && data.elements && Object.keys(data.elements).length > 0 && (
+
+ Top tag: {Object.keys(data.elements)[0]}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ {/* Subnavigation Bar & Search */}
+
+
+ {[
+ { id: "overview", label: "All Events", count: frontendEvents.length, icon: Layers },
+ { id: "crashes", label: "Crashes & Errors", count: totalErrors, icon: AlertCircle },
+ { id: "network", label: "HTTP Failures", count: totalHttpFailures, icon: WifiOff },
+ { id: "vitals", label: "Web Vitals Log", count: totalVitals, icon: Activity },
+ { id: "sessions", label: "User Sessions", count: sessions.length, icon: Monitor },
+ ].map((tab) => {
+ const Icon = tab.icon;
+ const isActive = activeSubTab === tab.id;
+ return (
+ {
+ setActiveSubTab(tab.id as any);
+ if (tab.id !== "vitals") setSelectedVitalFilter(null);
+ }}
+ className={`flex items-center gap-2 px-3 py-1.5 rounded-xl font-mono text-xs font-semibold transition-all ${
+ isActive
+ ? "bg-indigo-500 text-white shadow-md shadow-indigo-500/20"
+ : "bg-slate-900/60 text-slate-400 hover:text-slate-200 hover:bg-slate-800"
+ }`}
+ >
+
+ {tab.label}
+
+ {tab.count}
+
+
+ );
+ })}
+
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Filter URL, error, session..."
+ className="w-full rounded-xl border border-white/10 bg-slate-950/60 pl-8 pr-3 py-1.5 text-xs text-white placeholder-slate-500 outline-none focus:border-indigo-500 font-mono transition-colors"
+ />
+
+
+
+ {/* Main Content Area */}
+ {frontendEvents.length === 0 ? (
+ /* Empty State with Onboarding */
+
+
+
+
+
No Frontend Signals Detected Yet
+
+ Add the otel-rum.js script to your web
+ application to automatically stream Web Vitals, uncaught JS errors, and user journeys.
+
+
+
+
+
+ Simulate Real RUM Telemetry
+
+ setActiveTab("tab4")}
+ className="px-4 py-2 rounded-xl border border-white/10 bg-slate-900 hover:bg-slate-800 text-slate-300 font-mono text-xs font-semibold transition-all"
+ >
+ View Integration Docs
+
+
+
+ ) : activeSubTab === "sessions" ? (
+ /* User Sessions View */
+
+
+
+ Active Client Browser Sessions ({sessions.length})
+
+
+ Correlated user browser lifetimes
+
+
+
+
+ {sessions.map((sess) => {
+ const ua = parseUserAgent(sess.userAgent);
+ return (
+
+
+
+ {ua.isMobile ? (
+
+ ) : (
+
+ )}
+
+
+
+
{sess.sessionId}
+
+ {ua.browser} on {ua.os}
+
+ {sess.errorsCount > 0 ? (
+
+
+ {sess.errorsCount} {sess.errorsCount === 1 ? "error" : "errors"}
+
+ ) : (
+
+ Clean
+
+ )}
+
+
+ Visited:
+ {Array.from(sess.urls).slice(0, 3).map((u) => (
+
+ {u}
+
+ ))}
+ {sess.urls.size > 3 && (
+ +{sess.urls.size - 3} more
+ )}
+
+
+
+
+
+
+
{sess.eventsCount} events
+
+ {new Date(sess.lastSeen).toLocaleTimeString()}
+
+
+
setSearchQuery(sess.sessionId)}
+ className="px-2.5 py-1 rounded-lg border border-white/10 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[11px] transition-colors"
+ >
+ Filter Session
+
+
+
+ );
+ })}
+
+
+ ) : (
+ /* Event Stream View */
+
+
+
+ Frontend Telemetry Feed ({filteredEvents.length})
+
+
+ Showing {filteredEvents.length} of {frontendEvents.length} events
+
+
+
+
+ {filteredEvents.length === 0 ? (
+
+ No events match the selected filters or search query.
+
+ ) : (
+ filteredEvents.map((evt) => {
+ const isSelected = selectedEvent?.id === evt.id;
+ const ua = parseUserAgent(evt.userAgent);
+
+ return (
+
setSelectedEvent(evt)}
+ className={`p-4 flex flex-col gap-2.5 cursor-pointer transition-all ${
+ isSelected
+ ? "bg-indigo-500/10 border-l-4 border-l-indigo-500"
+ : "hover:bg-white/[0.02]"
+ }`}
+ >
+
+
+ {/* Event Category Badge */}
+ {evt.isVital ? (
+
+
+ {evt.vitalName}
+
+ ) : evt.isHttpFailure ? (
+
+
+ HTTP {evt.httpStatus || "ERR"}
+
+ ) : evt.isError ? (
+
+
+ {evt.exceptionType || "Error"}
+
+ ) : (
+
+ RUM Info
+
+ )}
+
+
+ {evt.isVital
+ ? `${evt.vitalName}: ${
+ VITAL_THRESHOLDS[evt.vitalName || ""]?.format(
+ evt.vitalValue || 0
+ ) || evt.vitalValue
+ } ${evt.vitalElement ? `(element: <${evt.vitalElement}>)` : ""}`
+ : evt.body}
+
+
+ {evt.path && (
+
+ {evt.path}
+
+ )}
+
+
+
+ {new Date(evt.timestamp).toLocaleTimeString()}
+
+
+
+
+ {/* Metadata chips */}
+
+ Service: {evt.service}
+ {ua.browser !== "Browser" && (
+ Client: {ua.browser} ({ua.os})
+ )}
+ {evt.viewport && Viewport: {evt.viewport} }
+ {evt.traceId && (
+
+ Trace: {evt.traceId.slice(0, 8)}...
+
+ )}
+ {evt.breadcrumbs.length > 0 && (
+
+ {evt.breadcrumbs.length} breadcrumbs recorded
+
+ )}
+
+
+ );
+ })
+ )}
+
+
+ )}
+
+ {/* Slide-out Event Detail Inspector Drawer */}
+ {selectedEvent && (
+
+
+
+
+
+ {selectedEvent.exceptionType || selectedEvent.vitalName || "Frontend Event"}
+
+
+ {selectedEvent.body}
+
+
+
+ Occurred at {new Date(selectedEvent.timestamp).toLocaleString()} on service{" "}
+ {selectedEvent.service}
+
+
+
+
setSelectedEvent(null)}
+ className="h-8 w-8 rounded-lg bg-slate-900 border border-white/10 text-slate-400 hover:text-white flex items-center justify-center transition-colors"
+ >
+ close
+
+
+
+
+ {/* Left Column: Context & Client info */}
+
+
+
+ Client & Navigation Environment
+
+ {selectedEvent.url && (
+
+ )}
+ {selectedEvent.sessionId && (
+
+ Browser Session ID:
+ {selectedEvent.sessionId}
+
+ )}
+ {selectedEvent.viewport && (
+
+ Viewport Size:
+ {selectedEvent.viewport}
+
+ )}
+ {selectedEvent.userAgent && (
+
+ User Agent:
+
+ {selectedEvent.userAgent}
+
+
+ )}
+
+
+ {/* Correlated Backend Trace */}
+ {selectedEvent.traceId && (
+
+
+
+ Correlated Backend Trace
+
+
+ {selectedEvent.traceId}
+
+
+
{
+ setGlobalSearchQuery(selectedEvent.traceId || "");
+ setActiveTab("tab1");
+ }}
+ className="px-3 py-1.5 rounded-lg bg-sky-500 hover:bg-sky-600 text-white font-mono text-xs font-bold transition-colors shrink-0 shadow-sm flex items-center gap-1.5"
+ >
+ View in Traces
+
+
+
+ )}
+
+
+ {/* Right Column: Breadcrumbs Timeline */}
+
+
+
+
+ User Journey Breadcrumbs ({selectedEvent.breadcrumbs.length})
+
+ Actions prior to event
+
+
+ {selectedEvent.breadcrumbs.length === 0 ? (
+
+ No breadcrumbs attached to this event.
+
+ ) : (
+
+ {selectedEvent.breadcrumbs.map((b, idx) => (
+
+
+ {b.category}
+
+ {b.message}
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Stacktrace if available */}
+ {selectedEvent.stacktrace && (
+
+
+
+
+ JavaScript Stacktrace
+
+
+ copyToClipboard(selectedEvent.stacktrace || "", "stacktrace")
+ }
+ className="flex items-center gap-1 px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 font-mono text-[10px] transition-colors"
+ >
+ {copiedKey === "stacktrace" ? (
+ <>
+
+ Copied
+ >
+ ) : (
+ <>
+
+ Copy
+ >
+ )}
+
+
+
+ {selectedEvent.stacktrace}
+
+
+ )}
+
+ )}
+
+ );
+};
+
+export default FrontendVisualizer;
diff --git a/otel-ui/src/components/GaugeMeter.tsx b/otel-ui/src/components/GaugeMeter.tsx
new file mode 100644
index 0000000..3ee2ea7
--- /dev/null
+++ b/otel-ui/src/components/GaugeMeter.tsx
@@ -0,0 +1,372 @@
+import { useMemo } from "react";
+import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react";
+import { getServicePalette } from "../lib/otel-utils";
+
+export interface GaugeMeterProps {
+ name: string;
+ service?: string;
+ value: number;
+ min?: number;
+ max?: number;
+ avg?: number;
+ unit?: string;
+ size?: "sm" | "md" | "lg";
+ thresholds?: {
+ warn: number;
+ danger: number;
+ };
+ subtitle?: string;
+ history?: number[];
+ showTrend?: boolean;
+ isSum?: boolean;
+ aggLabel?: string;
+ soloPod?: string;
+ podCount?: number;
+ podSummaries?: Array<{ pod: string; value: number }>;
+}
+
+const formatNumber = (val: number, maxDecimals = 2) => {
+ if (Math.abs(val) >= 1_000_000) {
+ return `${(val / 1_000_000).toFixed(1)}M`;
+ }
+ if (Math.abs(val) >= 1_000) {
+ return `${(val / 1_000).toFixed(1)}k`;
+ }
+ if (Number.isInteger(val)) {
+ return val.toString();
+ }
+ return val.toFixed(maxDecimals);
+};
+
+// SVG arc calculations
+const polarToCartesian = (centerX: number, centerY: number, radius: number, angleInDegrees: number) => {
+ const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
+ return {
+ x: centerX + radius * Math.cos(angleInRadians),
+ y: centerY + radius * Math.sin(angleInRadians),
+ };
+};
+
+const describeArc = (x: number, y: number, radius: number, startAngle: number, endAngle: number) => {
+ const start = polarToCartesian(x, y, radius, endAngle);
+ const end = polarToCartesian(x, y, radius, startAngle);
+ const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
+ return ["M", start.x, start.y, "A", radius, radius, 0, largeArcFlag, 0, end.x, end.y].join(" ");
+};
+
+export const GaugeMeter = ({
+ name,
+ service,
+ value,
+ min: customMin,
+ max: customMax,
+ avg,
+ unit = "",
+ size = "md",
+ thresholds,
+ subtitle,
+ history = [],
+ showTrend = true,
+ isSum = false,
+ aggLabel,
+ soloPod,
+ podCount,
+ podSummaries,
+}: GaugeMeterProps) => {
+ const servicePalette = service ? getServicePalette(service) : null;
+ // Determine min/max range
+ const { min, max } = useMemo(() => {
+ let computedMin = customMin !== undefined ? customMin : 0;
+ let computedMax = customMax !== undefined ? customMax : 100;
+
+ if (history.length > 0 && customMin === undefined && customMax === undefined) {
+ const histMin = Math.min(...history);
+ const histMax = Math.max(...history);
+ if (histMax <= 1 && histMin >= 0) {
+ // Percentage ratio 0..1
+ computedMin = 0;
+ computedMax = 1;
+ } else if (histMax <= 100 && histMin >= 0) {
+ // 0..100%
+ computedMin = 0;
+ computedMax = 100;
+ } else {
+ computedMin = Math.floor(Math.min(0, histMin));
+ computedMax = Math.ceil(histMax * 1.15) || 100;
+ }
+ }
+
+ if (computedMax <= computedMin) {
+ computedMax = computedMin + 1;
+ }
+
+ return { min: computedMin, max: computedMax };
+ }, [customMin, customMax, history]);
+
+ // Normalized percent 0..100
+ const percent = useMemo(() => {
+ const clamped = Math.max(min, Math.min(value, max));
+ return ((clamped - min) / (max - min)) * 100;
+ }, [value, min, max]);
+
+ // Color determination based on thresholds
+ const warnThreshold = thresholds?.warn ?? 70;
+ const dangerThreshold = thresholds?.danger ?? 90;
+
+ const statusColor = useMemo(() => {
+ if (percent >= dangerThreshold) {
+ return {
+ text: "text-rose-400",
+ stroke: "#f43f5e",
+ gradientStart: "#fb7185",
+ gradientEnd: "#e11d48",
+ bg: "bg-rose-500/10",
+ border: "border-rose-500/30",
+ glow: "rgba(244, 63, 94, 0.35)",
+ label: "Critical",
+ };
+ }
+ if (percent >= warnThreshold) {
+ return {
+ text: "text-amber-400",
+ stroke: "#f59e0b",
+ gradientStart: "#fbbf24",
+ gradientEnd: "#d97706",
+ bg: "bg-amber-500/10",
+ border: "border-amber-500/30",
+ glow: "rgba(245, 158, 11, 0.35)",
+ label: "Warning",
+ };
+ }
+ return {
+ text: "text-cyan-400",
+ stroke: "#06b6d4",
+ gradientStart: "#38bdf8",
+ gradientEnd: "#0284c7",
+ bg: "bg-cyan-500/10",
+ border: "border-cyan-500/30",
+ glow: "rgba(6, 182, 212, 0.35)",
+ label: "Nominal",
+ };
+ }, [percent, warnThreshold, dangerThreshold]);
+
+ // Trend calculation
+ const trend = useMemo(() => {
+ if (!showTrend || history.length < 2) return null;
+ const prev = history[history.length - 2];
+ const diff = value - prev;
+ if (Math.abs(diff) < 0.0001) return { direction: "flat", text: "steady", color: "text-slate-500" };
+ if (diff > 0) return { direction: "up", text: `+${formatNumber(diff)}`, color: "text-rose-400" };
+ return { direction: "down", text: `${formatNumber(diff)}`, color: "text-emerald-400" };
+ }, [value, history, showTrend]);
+
+ // Dimensions
+ const dim = size === "sm" ? 120 : size === "lg" ? 220 : 160;
+ const strokeWidth = size === "sm" ? 8 : size === "lg" ? 14 : 10;
+ const radius = (dim - strokeWidth * 2) / 2;
+ const centerX = dim / 2;
+ const centerY = dim / 2 + (size === "sm" ? 4 : 8);
+
+ // 240 degree arc from -120 to +120 degrees
+ const startAngle = -120;
+ const endAngle = 120;
+ const totalAngle = endAngle - startAngle;
+ const valueAngle = startAngle + (percent / 100) * totalAngle;
+
+ const bgArc = describeArc(centerX, centerY, radius, startAngle, endAngle);
+ const valueArc = describeArc(centerX, centerY, radius, startAngle, Math.min(valueAngle, endAngle));
+
+ // Ticks at 0, 50, 100
+ const tickAngles = [startAngle, startAngle + totalAngle * 0.5, endAngle];
+
+ const gaugeId = useMemo(
+ () => `${service ? service.replace(/[^a-zA-Z0-9]/g, "_") + "_" : ""}${name.replace(/[^a-zA-Z0-9]/g, "_")}`,
+ [service, name]
+ );
+
+ return (
+
+
+
+
+ {service && (
+
+
+ {service}
+
+ )}
+ {podCount !== undefined && podCount > 1 && (
+
+ ∑
+ {podCount} pods
+
+ )}
+
+
+ {name}
+
+
+
+ {statusColor.label}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Background Track */}
+
+
+ {/* Ticks */}
+ {tickAngles.map((angle, idx) => {
+ const inner = polarToCartesian(centerX, centerY, radius - strokeWidth * 0.8, angle);
+ const outer = polarToCartesian(centerX, centerY, radius + strokeWidth * 0.8, angle);
+ return (
+
+ );
+ })}
+
+ {/* Active Value Arc */}
+ {percent > 0 && (
+
+ )}
+
+ {/* Needle / Value dot indicator */}
+ {percent > 0 && (() => {
+ const tip = polarToCartesian(centerX, centerY, radius, valueAngle);
+ return (
+
+ );
+ })()}
+
+
+ {/* Center Readout */}
+
+ {soloPod ? (
+
+ {soloPod}
+
+ ) : aggLabel ? (
+
+ {aggLabel}
+
+ ) : podCount !== undefined && podCount > 1 ? (
+
+ {isSum ? "Sum (∑)" : "Avg (μ)"}
+
+ ) : null}
+
+
+ {formatNumber(value)}
+
+ {unit && (
+
+ {unit}
+
+ )}
+
+ {podCount !== undefined && podCount > 1 ? (
+
+ {isSum ? `~${formatNumber(value / podCount)}/pod` : `${podCount} pods avg`}
+
+ ) : subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+
+
+ {/* Footer range indicators & trend */}
+
+
+ min {formatNumber(min)}
+
+ {trend && (
+
+ {trend.direction === "up" && }
+ {trend.direction === "down" && }
+ {trend.direction === "flat" && }
+ {trend.text}
+
+ )}
+ {avg !== undefined && (
+
+ avg {formatNumber(avg)}
+
+ )}
+
+ max {formatNumber(max)}
+
+
+
+ {/* Pod Breakdown pills if available */}
+ {podSummaries && podSummaries.length > 1 && (
+
+ {podSummaries.slice(0, 3).map((ps) => (
+
+ {ps.pod}
+ {formatNumber(ps.value)}
+
+ ))}
+ {podSummaries.length > 3 && (
+
+ +{podSummaries.length - 3}
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/otel-ui/src/components/IssuesVisualizer.tsx b/otel-ui/src/components/IssuesVisualizer.tsx
new file mode 100644
index 0000000..5774c5e
--- /dev/null
+++ b/otel-ui/src/components/IssuesVisualizer.tsx
@@ -0,0 +1,601 @@
+import { useMemo, useState } from "react"
+import {
+ Activity,
+ AlertOctagon,
+ Bug,
+ CheckCircle2,
+ ChevronDown,
+ ChevronUp,
+ Clock,
+ EyeOff,
+ ExternalLink,
+ Globe,
+ MonitorSmartphone,
+ RefreshCw,
+ RotateCcw,
+ ScrollText,
+ ShieldBan,
+ X,
+} from "lucide-react"
+import { useObservabilityData } from "../hooks/useObservabilityData"
+import { authFetch } from "../lib/auth"
+import { formatRelativeTime } from "../lib/timeUtils"
+import { getServicePalette } from "../lib/otel-utils"
+import {
+ issueStatusMeta,
+ issueStatusOf,
+ latestBreadcrumbs,
+ latestStacktrace,
+ sampleContext,
+ trendPoints,
+} from "../lib/issue-utils"
+import type { Issue, IssueSample } from "../lib/types"
+
+const kindMeta: Record = {
+ span_error: { label: "Trace Error", Icon: Activity },
+ log_error: { label: "Log Error", Icon: ScrollText },
+ frontend_error: { label: "Frontend Error", Icon: Globe },
+}
+
+type StatusFilter = "all" | "open" | "resolved" | "ignored"
+type KindFilter = "all" | "span_error" | "log_error" | "frontend_error"
+
+interface HoneypotIP {
+ ip: string
+ count: number
+ first_seen: string
+ last_seen: string
+ promoted_until: string
+}
+
+const statusFilters: { value: StatusFilter; label: string }[] = [
+ { value: "all", label: "All" },
+ { value: "open", label: "Open" },
+ { value: "resolved", label: "Resolved" },
+ { value: "ignored", label: "Ignored" },
+]
+
+const kindFilters: { value: KindFilter; label: string; Icon: typeof Activity }[] = [
+ { value: "all", label: "All kinds", Icon: Bug },
+ { value: "span_error", label: "Traces", Icon: Activity },
+ { value: "log_error", label: "Logs", Icon: ScrollText },
+ { value: "frontend_error", label: "Frontend", Icon: Globe },
+]
+
+// Lightweight inline-SVG sparkline of the issue's hourly occurrence trend.
+// Deliberately not recharts: issue lists can hold dozens of cards and each
+// recharts instance carries meaningful render cost.
+const TrendSparkline = ({ issue }: { issue: Issue }) => {
+ const points = trendPoints(issue, 24)
+ const max = Math.max(...points.map((p) => p.count), 1)
+ const width = 96
+ const height = 24
+ const step = width / (points.length - 1)
+ const path = points
+ .map((p, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(height - (p.count / max) * (height - 2) - 1).toFixed(1)}`)
+ .join(" ")
+ const gradientId = `sparkFill-${issue.id}`
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+
+const StatusBadge = ({ issue }: { issue: Issue }) => {
+ const status = issueStatusOf(issue)
+ const meta = issueStatusMeta[status]
+ return (
+
+ {meta.label}
+
+ )
+}
+
+const StacktraceView = ({ stacktrace }: { stacktrace: string }) => (
+
+ {stacktrace}
+
+)
+
+const BreadcrumbsView = ({ issue }: { issue: Issue }) => {
+ const crumbs = latestBreadcrumbs(issue)
+ if (!crumbs.length) return null
+ return (
+
+
+
+ Breadcrumbs before error
+
+
+ {crumbs.map((crumb, idx) => (
+
+
+ {new Date(crumb.t).toLocaleTimeString([], { hour12: false })}
+
+
+ {crumb.category}
+
+ {crumb.message.slice(0, 200)}
+
+ ))}
+
+
+ )
+}
+
+const SampleContextChips = ({ sample }: { sample: IssueSample }) => {
+ const ctx = sampleContext(sample)
+ if (!ctx.length) return null
+ return (
+
+ {ctx.join(" · ")}
+
+ )
+}
+
+const StatusActions = ({
+ issue,
+ onStatusChange,
+ busy,
+}: {
+ issue: Issue
+ onStatusChange: (issue: Issue, status: string) => void
+ busy: boolean
+}) => {
+ const status = issueStatusOf(issue)
+ const actions: { to: string; label: string; Icon: typeof CheckCircle2 }[] =
+ status === "open"
+ ? [
+ { to: "resolved", label: "Resolve", Icon: CheckCircle2 },
+ { to: "ignored", label: "Ignore", Icon: EyeOff },
+ ]
+ : [{ to: "open", label: "Reopen", Icon: RotateCcw }]
+
+ return (
+
+ {actions.map(({ to, label, Icon }) => (
+ {
+ e.stopPropagation()
+ onStatusChange(issue, to)
+ }}
+ className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 transition-all hover:border-white/20 hover:text-white disabled:opacity-50"
+ >
+
+ {label}
+
+ ))}
+
+ )
+}
+
+const IssueCard = ({
+ issue,
+ onStatusChange,
+ statusBusy,
+}: {
+ issue: Issue
+ onStatusChange: (issue: Issue, status: string) => void
+ statusBusy: boolean
+}) => {
+ const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
+ const [expanded, setExpanded] = useState(false)
+
+ const meta = kindMeta[issue.kind] ?? { label: issue.kind, Icon: Bug }
+ const isFrontend = issue.kind === "frontend_error"
+ const badgeGradient = isFrontend
+ ? "from-violet-500 via-sky-500 to-cyan-400"
+ : "from-rose-500 via-red-500 to-orange-400"
+ const palette = getServicePalette(issue.service)
+ const latestSample = issue.samples[issue.samples.length - 1]
+ const stacktrace = latestStacktrace(issue)
+ const hasMappedStacktrace = issue.samples.some((sample) => Boolean(sample.resolvedStacktrace))
+ const contextSample = issue.samples.find((s) => s.httpMethod || s.httpRoute || s.httpStatus)
+
+ const viewTrace = (traceId: string, spanId?: string) => {
+ setActiveTraceId(traceId)
+ if (spanId) setActiveSpanId(spanId)
+ setActiveTab("tab1")
+ }
+
+ return (
+
+
+
+
setExpanded((prev) => !prev)}
+ className="flex cursor-pointer flex-wrap items-center gap-x-4 gap-y-2 p-3 pl-4"
+ >
+
+
+ {meta.label}
+
+
+
+ {issue.service}
+
+
+
+ {issue.title}
+
+
+
+
+
+ ×{issue.count}
+
+
+
+
+
+ last seen {formatRelativeTime(issue.lastSeen / 1e6)}
+
+
+ {expanded ? : }
+
+
+ {expanded && (
+
+
+
+ First seen {formatRelativeTime(issue.firstSeen / 1e6)}
+ {issue.affectedServices.length > 1 && (
+ Affects: {issue.affectedServices.join(", ")}
+ )}
+ {issue.status === "resolved" && issue.resolvedAt ? (
+ Resolved {formatRelativeTime(issue.resolvedAt / 1e6)}
+ ) : null}
+
+
+
+
+ {stacktrace && (
+
+
+ {hasMappedStacktrace ? "Source-mapped stacktrace" : "Stacktrace"}
+
+
+
+ )}
+
+
+
+
+ {(contextSample?.userAgent || latestSample?.userAgent) && (
+
+
+ {(contextSample?.userAgent || latestSample?.userAgent)?.slice(0, 80)}
+
+ )}
+ {(contextSample?.release || latestSample?.release) && (
+ release: {contextSample?.release || latestSample?.release}
+ )}
+ {(contextSample?.environment || latestSample?.environment) && (
+ env: {contextSample?.environment || latestSample?.environment}
+ )}
+
+
+
+
+ Recent occurrences ({issue.samples.length})
+
+
+ {[...issue.samples].reverse().map((sample, idx) => (
+
+ ))}
+
+
+
+ {latestSample?.traceId && (
+
+
viewTrace(latestSample.traceId!, latestSample.spanId)}
+ className="inline-flex items-center gap-2 rounded-xl bg-sky-500 px-4 py-2 text-[10px] font-black uppercase tracking-widest text-slate-950 transition-all hover:bg-sky-400 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-sky-500/20"
+ >
+
+ View Latest Trace
+
+
+ )}
+
+ )}
+
+ )
+}
+
+interface IssuesVisualizerProps {
+ issues: Issue[]
+ loading: boolean
+ error: string | null
+ refresh: () => void
+ onStatusChange: (issue: Issue, status: string) => void
+ statusBusy: boolean
+}
+
+export const IssuesVisualizer = ({
+ issues,
+ loading,
+ error,
+ refresh,
+ onStatusChange,
+ statusBusy,
+}: IssuesVisualizerProps) => {
+ const { selectedServices, searchQuery } = useObservabilityData()
+ const [statusFilter, setStatusFilter] = useState("all")
+ const [kindFilter, setKindFilter] = useState("all")
+ const [excludeSpamIPs, setExcludeSpamIPs] = useState(false)
+ const [showSpamIPs, setShowSpamIPs] = useState(false)
+ const [spamIPs, setSpamIPs] = useState([])
+ const [spamIPsLoading, setSpamIPsLoading] = useState(false)
+ const [spamIPsError, setSpamIPsError] = useState(null)
+
+ const loadSpamIPs = async () => {
+ setSpamIPsLoading(true)
+ setSpamIPsError(null)
+ try {
+ const response = await authFetch("/api/honeypot/ips")
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
+ setSpamIPs((await response.json()) as HoneypotIP[])
+ } catch (err) {
+ setSpamIPsError(err instanceof Error ? err.message : String(err))
+ } finally {
+ setSpamIPsLoading(false)
+ }
+ }
+
+ const spamIPSet = useMemo(() => new Set(spamIPs.map((entry) => entry.ip)), [spamIPs])
+
+ const statusCounts = useMemo(() => {
+ const counts = { all: issues.length, open: 0, resolved: 0, ignored: 0 }
+ issues.forEach((issue) => {
+ counts[issueStatusOf(issue)] += 1
+ })
+ return counts
+ }, [issues])
+
+ const filteredIssues = useMemo(() => {
+ return issues.filter((issue) => {
+ if (statusFilter !== "all" && issueStatusOf(issue) !== statusFilter) return false
+ if (kindFilter !== "all" && issue.kind !== kindFilter) return false
+ if (
+ excludeSpamIPs &&
+ issue.samples.some(
+ (sample) =>
+ (sample.httpStatus ?? 0) >= 400 &&
+ Boolean(sample.sourceIp) &&
+ spamIPSet.has(sample.sourceIp!),
+ )
+ ) {
+ return false
+ }
+ if (selectedServices.size > 0) {
+ const matches =
+ selectedServices.has(issue.service) ||
+ issue.affectedServices.some((s) => selectedServices.has(s))
+ if (!matches) return false
+ }
+ if (searchQuery) {
+ const q = searchQuery.toLowerCase()
+ const matches =
+ issue.title.toLowerCase().includes(q) || issue.service.toLowerCase().includes(q)
+ if (!matches) return false
+ }
+ return true
+ })
+ }, [issues, selectedServices, searchQuery, statusFilter, kindFilter, excludeSpamIPs, spamIPSet])
+
+ if (loading && issues.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ Deduplicated Issues
+
+
+ {/* Status filter chips */}
+
+ {statusFilters.map(({ value, label }) => (
+ setStatusFilter(value)}
+ className={`rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider transition-all ${
+ statusFilter === value
+ ? "bg-sky-500/20 text-sky-300 ring-1 ring-sky-500/30"
+ : "text-slate-500 hover:text-slate-300"
+ }`}
+ >
+ {label}
+ {statusCounts[value]}
+
+ ))}
+
+
+
setExcludeSpamIPs((value) => !value)}
+ className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider transition-all ${
+ excludeSpamIPs
+ ? "border-amber-400/40 bg-amber-400/15 text-amber-200"
+ : "border-white/10 text-slate-500 hover:border-white/20 hover:text-slate-300"
+ }`}
+ title="Hide HTTP errors whose recorded source IP is on the honeypot list"
+ >
+
+ {excludeSpamIPs ? "Spam IPs hidden" : "Hide spam IPs"}
+
+
+ {/* Kind filter chips */}
+
+ {kindFilters.map(({ value, label, Icon }) => (
+ setKindFilter(value)}
+ className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider transition-all ${
+ kindFilter === value
+ ? "bg-violet-500/20 text-violet-300 ring-1 ring-violet-500/30"
+ : "text-slate-500 hover:text-slate-300"
+ }`}
+ >
+
+ {label}
+
+ ))}
+
+
+
+ {filteredIssues.length} shown
+
+
+
+
+ {error && Failed to load: {error} }
+ {
+ const next = !showSpamIPs
+ setShowSpamIPs(next)
+ if (next && spamIPs.length === 0) void loadSpamIPs()
+ }}
+ className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white hover:border-white/20 transition-all"
+ >
+
+ Spam IPs ({spamIPs.length})
+
+ refresh()}
+ className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white hover:border-white/20 transition-all"
+ >
+
+ Refresh
+
+
+
+
+ {showSpamIPs && (
+
+
+
+
Promoted honeypot IPs
+
Used by the ingress tarpit synchronizer.
+
+
setShowSpamIPs(false)}
+ className="rounded p-1 text-slate-500 hover:text-white"
+ aria-label="Close spam IP list"
+ >
+
+
+
+ {spamIPsLoading &&
Loading…
}
+ {spamIPsError &&
Failed to load: {spamIPsError}
}
+ {!spamIPsLoading && !spamIPsError && spamIPs.length === 0 && (
+
No promoted IPs.
+ )}
+ {spamIPs.length > 0 && (
+
+ {spamIPs.map((entry) => (
+
+ {entry.ip}
+ ×{entry.count}
+
+ ))}
+
+ )}
+
+ )}
+
+ {filteredIssues.length === 0 ? (
+
+
+
+ {issues.length === 0 ? "No issues detected" : "No issues match your filters"}
+
+
+ {issues.length === 0
+ ? "Error-status spans and error/fatal-severity logs will show up here as they arrive."
+ : "Try adjusting your filters or search query."}
+
+
+ ) : (
+
+ {filteredIssues.map((issue) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+export default IssuesVisualizer
diff --git a/otel-ui/src/components/MetricsVisualizer.tsx b/otel-ui/src/components/MetricsVisualizer.tsx
new file mode 100644
index 0000000..c0a4a9d
--- /dev/null
+++ b/otel-ui/src/components/MetricsVisualizer.tsx
@@ -0,0 +1,1107 @@
+import { useMemo, useState } from "react";
+import {
+ AreaChart,
+ Area,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+} from "recharts";
+import { Activity, BarChart3, Sparkles, Filter, Gauge, Search, X, Boxes, Eye } from "lucide-react";
+import { useMetricsDetails } from "../hooks/useMetrics";
+import type { MetricDataPoint, OtelAttribute } from "../lib/types";
+import { formatAttributeValue, getServicePalette, getPodPalette } from "../lib/otel-utils";
+import { GaugeMeter } from "./GaugeMeter";
+
+const valueFormatter = new Intl.NumberFormat("en-US", {
+ maximumFractionDigits: 3,
+});
+
+const compactFormatter = new Intl.NumberFormat("en-US", {
+ maximumFractionDigits: 2,
+ notation: "compact",
+});
+
+const resolveValue = (point: MetricDataPoint) => {
+ if (typeof point.asDouble === "number") return point.asDouble;
+ if (point.asInt !== undefined) return Number(BigInt(point.asInt));
+ return 0;
+};
+
+interface MetricCardProps {
+ name: string;
+ service: string;
+ type: "gauge" | "sum" | "histogram";
+ points: MetricDataPoint[];
+ unit?: string;
+ pods?: string[];
+ index: number;
+}
+
+const MetricCard = ({ name, service, type, points, unit, pods = [], index }: MetricCardProps) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [viewMode, setViewMode] = useState<"chart" | "gauge">("chart");
+ const [selectedPod, setSelectedPod] = useState("all");
+
+ const isGaugeLike = useMemo(() => {
+ if (type === "gauge") return true;
+ const lower = name.toLowerCase();
+ return (
+ lower.includes("cpu") ||
+ lower.includes("memory") ||
+ lower.includes("usage") ||
+ lower.includes("ratio") ||
+ lower.includes("utilization") ||
+ lower.includes("duration") ||
+ lower.includes("latency") ||
+ lower.includes("rate") ||
+ lower.includes("load") ||
+ lower.includes("temp") ||
+ unit === "%"
+ );
+ }, [type, name, unit]);
+
+ const [aggMode, setAggMode] = useState<"avg" | "lines" | "sum" | "max" | "stacked">(
+ isGaugeLike ? "avg" : "sum"
+ );
+
+ const servicePalette = useMemo(() => getServicePalette(service), [service]);
+
+ // Compute latest per-pod values
+ const podLatestMap = useMemo(() => {
+ const map = new Map();
+ points.forEach((p) => {
+ const pod = p.pod || "default";
+ map.set(pod, resolveValue(p));
+ });
+ return map;
+ }, [points]);
+
+ const activePods = useMemo(() => {
+ if (pods && pods.length > 0) return pods;
+ const fromMap = Array.from(podLatestMap.keys()).filter((p) => p !== "default");
+ return fromMap.length > 0 ? fromMap : [];
+ }, [pods, podLatestMap]);
+
+ const isMultiPod = activePods.length > 1;
+
+ const podSummaries = useMemo(() => {
+ return Array.from(podLatestMap.entries()).map(([pod, value]) => ({
+ pod,
+ value,
+ }));
+ }, [podLatestMap]);
+
+ // Build chart dataset with time alignment and zero-order hold
+ const chartData = useMemo(() => {
+ // 1. Solo Mode: filter directly to the chosen pod
+ if (selectedPod !== "all") {
+ const podPoints = points
+ .filter((p) => (p.pod || "default") === selectedPod)
+ .sort((a, b) => {
+ const aT = BigInt(a.timeUnixNano || "0");
+ const bT = BigInt(b.timeUnixNano || "0");
+ return aT < bT ? -1 : aT > bT ? 1 : 0;
+ });
+
+ return podPoints.map((p) => {
+ const val = resolveValue(p);
+ return {
+ time: Number(BigInt(p.timeUnixNano ?? "0") / 1000000n),
+ value: val,
+ total: val,
+ avg: val,
+ min: val,
+ max: val,
+ [selectedPod]: val,
+ attributes: p.attributes ?? [],
+ };
+ });
+ }
+
+ // 2. Single pod (or no pod attributes present)
+ if (!isMultiPod) {
+ const sorted = [...points].sort((a, b) => {
+ const aT = BigInt(a.timeUnixNano || "0");
+ const bT = BigInt(b.timeUnixNano || "0");
+ return aT < bT ? -1 : aT > bT ? 1 : 0;
+ });
+ return sorted.map((p) => {
+ const val = resolveValue(p);
+ return {
+ time: Number(BigInt(p.timeUnixNano ?? "0") / 1000000n),
+ value: val,
+ total: val,
+ avg: val,
+ min: val,
+ max: val,
+ attributes: p.attributes ?? [],
+ };
+ });
+ }
+
+ // 3. Multi-pod: bucket time series with sample-and-hold so pod scrape jitter doesn't cause sawteeth
+ const times = points
+ .map((p) => Number(BigInt(p.timeUnixNano ?? "0") / 1000000n))
+ .filter((t) => t > 0);
+ if (times.length === 0) return [];
+
+ const minT = Math.min(...times);
+ const maxT = Math.max(...times);
+ const span = Math.max(maxT - minT, 1000);
+ const numBuckets = Math.min(50, Math.max(12, Math.floor(points.length / Math.max(1, activePods.length))));
+ const bucketDuration = span / numBuckets;
+
+ const bucketPoints: Array> = Array.from({ length: numBuckets }, () => ({}));
+
+ points.forEach((p) => {
+ const t = Number(BigInt(p.timeUnixNano ?? "0") / 1000000n);
+ if (t < minT || t > maxT) return;
+ const bIdx = Math.min(numBuckets - 1, Math.max(0, Math.floor((t - minT) / bucketDuration)));
+ const pod = p.pod || activePods[0] || "default";
+ const val = resolveValue(p);
+ if (!bucketPoints[bIdx][pod]) {
+ bucketPoints[bIdx][pod] = [];
+ }
+ bucketPoints[bIdx][pod].push(val);
+ });
+
+ const lastKnownPodValue: Record = {};
+ const buckets: Array<{
+ time: number;
+ avg: number;
+ sum: number;
+ min: number;
+ max: number;
+ value: number;
+ total: number;
+ podCount: number;
+ attributes: OtelAttribute[];
+ [pod: string]: any;
+ }> = [];
+
+ for (let i = 0; i < numBuckets; i++) {
+ const bTime = Math.round(minT + (i + 0.5) * bucketDuration);
+ const podEntries = bucketPoints[i];
+
+ // Update last known pod values with latest observed in this window
+ activePods.forEach((pod) => {
+ if (podEntries[pod] && podEntries[pod].length > 0) {
+ const vals = podEntries[pod];
+ lastKnownPodValue[pod] = vals[vals.length - 1];
+ }
+ });
+
+ const currentValues = activePods
+ .map((pod) => lastKnownPodValue[pod])
+ .filter((v): v is number => v !== undefined);
+
+ if (currentValues.length === 0) continue;
+
+ const sum = currentValues.reduce((a, b) => a + b, 0);
+ const avg = sum / currentValues.length;
+ const min = Math.min(...currentValues);
+ const max = Math.max(...currentValues);
+
+ let activeVal = avg;
+ if (aggMode === "sum" || aggMode === "stacked") {
+ activeVal = sum;
+ } else if (aggMode === "max") {
+ activeVal = max;
+ }
+
+ const row: any = {
+ time: bTime,
+ avg,
+ sum,
+ min,
+ max,
+ value: activeVal,
+ total: sum,
+ podCount: currentValues.length,
+ attributes: [],
+ };
+
+ activePods.forEach((pod) => {
+ row[pod] = lastKnownPodValue[pod] ?? 0;
+ });
+
+ buckets.push(row);
+ }
+
+ return buckets;
+ }, [points, selectedPod, isMultiPod, activePods, aggMode]);
+
+ const stats = useMemo(() => {
+ if (chartData.length === 0) return null;
+ const values = chartData.map((d) => d.value);
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const avg = values.reduce((a: number, b: number) => a + b, 0) / values.length;
+ return { min, max, avg, count: chartData.length };
+ }, [chartData]);
+
+ const latestPoint = chartData[chartData.length - 1];
+ const historyValues = useMemo(() => chartData.map((d) => d.value), [chartData]);
+
+ // Current KPI readout
+ const currentDisplay = useMemo(() => {
+ if (selectedPod !== "all") {
+ const val = podLatestMap.get(selectedPod) ?? (latestPoint ? latestPoint.value : 0);
+ return {
+ value: val,
+ label: "Solo Pod",
+ subtext: selectedPod,
+ };
+ }
+ if (!isMultiPod) {
+ const val = latestPoint ? latestPoint.value : 0;
+ return {
+ value: val,
+ label: "Current",
+ subtext: undefined,
+ };
+ }
+ const vals = Array.from(podLatestMap.values());
+ const sum = vals.reduce((a, b) => a + b, 0);
+ const avg = vals.length > 0 ? sum / vals.length : 0;
+ const max = vals.length > 0 ? Math.max(...vals) : 0;
+
+ if (aggMode === "sum" || aggMode === "stacked") {
+ return {
+ value: sum,
+ label: "Fleet Sum (∑)",
+ subtext: `~${valueFormatter.format(avg)}/pod`,
+ };
+ }
+ if (aggMode === "max") {
+ return {
+ value: max,
+ label: "Fleet Max",
+ subtext: `Peak of ${activePods.length} pods`,
+ };
+ }
+ return {
+ value: avg,
+ label: "Fleet Avg (μ)",
+ subtext: `Across ${activePods.length} pods (∑: ${compactFormatter.format(sum)})`,
+ };
+ }, [selectedPod, isMultiPod, podLatestMap, latestPoint, aggMode, activePods]);
+
+ const gradients = [
+ { from: "#818CF8", to: "#38BDF8" },
+ { from: "#34D399", to: "#38BDF8" },
+ { from: "#F472B6", to: "#FB7185" },
+ { from: "#FBBF24", to: "#F59E0B" },
+ ];
+ const color = gradients[index % gradients.length];
+ const gradientId = `gradient-${index}-${name.replace(/[^a-zA-Z0-9]/g, "_")}`;
+
+ const CustomTooltip = ({ active, payload }: any) => {
+ if (active && payload && payload.length) {
+ const data = payload[0].payload;
+ return (
+
+
+
+ {new Date(data.time).toLocaleTimeString([], {
+ hour12: false,
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ fractionalSecondDigits: 3,
+ })}
+
+
+
+ {selectedPod !== "all"
+ ? "Solo Value"
+ : isMultiPod
+ ? aggMode === "avg"
+ ? "Fleet Avg (μ)"
+ : aggMode === "sum" || aggMode === "stacked"
+ ? "Fleet Sum (∑)"
+ : aggMode === "max"
+ ? "Fleet Max"
+ : "Value"
+ : "Value"}
+
+
+ {valueFormatter.format(data.value ?? data.avg ?? data.total ?? 0)} {unit || ""}
+
+
+
+
+ {/* If fleet average, show min-to-max envelope in tooltip */}
+ {isMultiPod && selectedPod === "all" && data.min !== undefined && data.max !== undefined && (
+
+ Spread:
+
+ min {valueFormatter.format(data.min)} • max{" "}
+ {valueFormatter.format(data.max)}
+
+
+ )}
+
+ {/* Pod breakdown in tooltip */}
+ {isMultiPod && selectedPod === "all" && (
+
+
+ Pod Breakdown ({activePods.length}):
+ click pod to solo
+
+ {activePods.map((pod) => {
+ const pal = getPodPalette(pod);
+ const val = data[pod] ?? 0;
+ return (
+
+
+
+ {pod}
+
+
+ {valueFormatter.format(val)} {unit || ""}
+
+
+ );
+ })}
+
+ )}
+
+
+ Service:
+ {service}
+
+
+ {data.attributes && data.attributes.length > 0 && (
+
+ {data.attributes.map((attr: OtelAttribute) => (
+
+ {attr.key}:
+
+ {formatAttributeValue(attr.value)}
+
+
+ ))}
+
+ )}
+
+ );
+ }
+ return null;
+ };
+
+ return (
+
+
+
+
+ {type === "gauge" ? (
+
+ ) : index % 2 === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {service}
+
+
+ {/* Solo Indicator if a pod is isolated */}
+ {selectedPod !== "all" ? (
+ setSelectedPod("all")}
+ className="inline-flex items-center gap-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-mono font-bold uppercase tracking-wider text-amber-300 border border-amber-500/30 hover:bg-amber-500/25 transition-all"
+ title="Click to exit solo mode and show all pods"
+ >
+
+ Solo: {selectedPod}
+
+
+ ) : isMultiPod ? (
+
+
+ {activePods.length} pods
+
+ ) : null}
+
+
+ {type}
+
+
+
+
+ {name}
+
+
+ {stats?.count} samples {unit ? `• ${unit}` : ""}
+
+
+
+
+
+ {/* Multi-pod aggregation selector if in chart mode and not soloed */}
+ {viewMode === "chart" && isMultiPod && selectedPod === "all" && (
+
+ setAggMode("avg")}
+ className={`px-2 py-1 rounded text-[10px] font-bold transition-all ${
+ aggMode === "avg"
+ ? "bg-sky-500/20 text-sky-300 shadow"
+ : "text-slate-500 hover:text-slate-300"
+ }`}
+ title="Fleet Average (mean across pods with min-max envelope)"
+ >
+ Avg (μ)
+
+ setAggMode("lines")}
+ className={`px-2 py-1 rounded text-[10px] font-bold transition-all ${
+ aggMode === "lines"
+ ? "bg-sky-500/20 text-sky-300 shadow"
+ : "text-slate-500 hover:text-slate-300"
+ }`}
+ title="Overlay Lines (individual per-pod comparison)"
+ >
+ Lines
+
+ setAggMode("max")}
+ className={`px-2 py-1 rounded text-[10px] font-bold transition-all ${
+ aggMode === "max"
+ ? "bg-sky-500/20 text-sky-300 shadow"
+ : "text-slate-500 hover:text-slate-300"
+ }`}
+ title="Fleet Maximum (peak / outlier pod over time)"
+ >
+ Max
+
+ setAggMode("sum")}
+ className={`px-2 py-1 rounded text-[10px] font-bold transition-all ${
+ aggMode === "sum"
+ ? "bg-sky-500/20 text-sky-300 shadow"
+ : "text-slate-500 hover:text-slate-300"
+ }`}
+ title="Fleet Total (sum of all pods)"
+ >
+ Sum (∑)
+
+ setAggMode("stacked")}
+ className={`px-2 py-1 rounded text-[10px] font-bold transition-all ${
+ aggMode === "stacked"
+ ? "bg-sky-500/20 text-sky-300 shadow"
+ : "text-slate-500 hover:text-slate-300"
+ }`}
+ title="Stacked Area (cumulative volume)"
+ >
+ Stacked
+
+
+ )}
+
+ {/* View switcher: Gauge vs Timeline Chart */}
+
+
setViewMode("chart")}
+ className={`px-2 py-1 rounded text-[10px] font-bold transition-all ${
+ viewMode === "chart"
+ ? "bg-white/10 text-white shadow"
+ : "text-slate-500 hover:text-slate-300"
+ }`}
+ title="Time series area chart"
+ >
+
+
+
setViewMode("gauge")}
+ className={`px-2 py-1 rounded text-[10px] font-bold transition-all ${
+ viewMode === "gauge"
+ ? "bg-cyan-500/20 text-cyan-300 shadow"
+ : "text-slate-500 hover:text-slate-300"
+ }`}
+ title="Gauge dial view"
+ >
+
+
+
+
+
+
+ {currentDisplay.label}
+
+
+ {valueFormatter.format(currentDisplay.value)}
+ {unit && {unit} }
+
+ {currentDisplay.subtext && (
+
+ {currentDisplay.subtext}
+
+ )}
+
+
+
+
+ {/* Body: either Gauge or Area Chart */}
+ {viewMode === "gauge" ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+ {activePods.map((pod) => {
+ const pal = getPodPalette(pod);
+ const safePodId = pod.replace(/[^a-zA-Z0-9]/g, "_");
+ return (
+
+
+
+
+ );
+ })}
+
+
+
+ new Date(t).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ })
+ }
+ stroke="#475569"
+ fontSize={10}
+ tickLine={false}
+ axisLine={false}
+ minTickGap={30}
+ />
+ compactFormatter.format(v)}
+ width={36}
+ />
+ } />
+
+ {/* Render series based on Solo mode or Aggregation mode */}
+ {selectedPod !== "all" ? (
+
+ ) : !isMultiPod ? (
+
+ ) : aggMode === "avg" ? (
+ <>
+
+
+
+ >
+ ) : aggMode === "lines" ? (
+ activePods.map((pod) => {
+ const pal = getPodPalette(pod);
+ return (
+
+ );
+ })
+ ) : aggMode === "stacked" ? (
+ <>
+ {activePods.map((pod) => {
+ const pal = getPodPalette(pod);
+ const safePodId = pod.replace(/[^a-zA-Z0-9]/g, "_");
+ return (
+
+ );
+ })}
+
+ >
+ ) : aggMode === "max" ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+
+ {/* Pod breakdown pill list with click-to-solo */}
+ {isMultiPod && (
+
+
+
+ Pods ({activePods.length}):
+
+
+ {selectedPod !== "all" && (
+
setSelectedPod("all")}
+ className="inline-flex items-center gap-1 rounded-lg px-2 py-0.5 text-[10px] font-mono font-bold bg-white/10 hover:bg-white/20 text-white transition-all border border-white/10"
+ title="Show all pods"
+ >
+ All Pods
+
+ )}
+
+ {podSummaries.map((ps) => {
+ const pal = getPodPalette(ps.pod);
+ const isSelected = selectedPod === ps.pod;
+ return (
+
setSelectedPod(isSelected ? "all" : ps.pod)}
+ className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-0.5 text-[10px] font-mono border transition-all ${
+ isSelected
+ ? "bg-amber-500/20 text-amber-200 border-amber-400/50 ring-2 ring-amber-400/30 shadow-md font-bold"
+ : `${pal.bg} ${pal.border} hover:border-white/20 hover:brightness-110`
+ }`}
+ title={`Click to ${isSelected ? "unsolo" : "solo"} ${ps.pod} (${valueFormatter.format(ps.value)} ${unit || ""})`}
+ >
+
+ {ps.pod}
+
+ {valueFormatter.format(ps.value)}
+
+ {isSelected && SOLO }
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+};
+
+export const MetricsVisualizer = () => {
+ const metricsDetails = useMetricsDetails();
+ const [filterType, setFilterType] = useState<"all" | "gauge" | "sum" | "histogram">("all");
+ const [selectedServiceFilter, setSelectedServiceFilter] = useState("all");
+ const [search, setSearch] = useState("");
+
+ const metricList = useMemo(() => {
+ return Object.values(metricsDetails ?? {}).filter((m) => m.points.length > 0);
+ }, [metricsDetails]);
+
+ const availableServices = useMemo(() => {
+ const set = new Set();
+ metricList.forEach((m) => {
+ if (m.service) set.add(m.service);
+ });
+ return Array.from(set).sort();
+ }, [metricList]);
+
+ const filteredMetrics = useMemo(() => {
+ return metricList.filter((m) => {
+ if (filterType !== "all" && m.type !== filterType) return false;
+ if (selectedServiceFilter !== "all" && m.service !== selectedServiceFilter) return false;
+ if (
+ search.trim() &&
+ !m.name.toLowerCase().includes(search.toLowerCase().trim()) &&
+ !m.service.toLowerCase().includes(search.toLowerCase().trim())
+ ) {
+ return false;
+ }
+ return true;
+ });
+ }, [metricList, filterType, selectedServiceFilter, search]);
+
+ // Top gauges for the KPI bar
+ const topGauges = useMemo(() => {
+ return metricList.filter((m) => m.type === "gauge" || m.name.toLowerCase().includes("memory") || m.name.toLowerCase().includes("cpu") || m.name.toLowerCase().includes("usage") || m.name.toLowerCase().includes("ratio")).slice(0, 4);
+ }, [metricList]);
+
+ if (metricList.length === 0) {
+ return (
+
+
+
+
+
+
No metric signals detected
+
+ The telemetry pipeline is established and waiting for metrics. Send gauge, counter, or histogram data to see interactive gauges and trend visualizations.
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Top Gauges Overview Dashboard */}
+ {topGauges.length > 0 && (
+
+
+
+
+
+ Live Gauge Dashboard
+
+
+
+ {topGauges.length} Active Gauges
+
+
+
+
+ {topGauges.map((g) => {
+ const podLatestMap = new Map();
+ g.points.forEach((p) => {
+ const pod = p.pod || "default";
+ podLatestMap.set(pod, resolveValue(p));
+ });
+ const podCount = (g.pods && g.pods.length > 0) ? g.pods.length : (podLatestMap.size > 1 ? podLatestMap.size : 1);
+ const isMulti = podCount > 1;
+ const podSum = Array.from(podLatestMap.values()).reduce((a, b) => a + b, 0);
+ const isGauge = g.type === "gauge" || g.name.toLowerCase().includes("cpu") || g.name.toLowerCase().includes("memory") || g.name.toLowerCase().includes("usage") || g.name.toLowerCase().includes("ratio") || g.unit === "%";
+ const fleetAvg = isMulti ? podSum / podCount : podSum;
+ const displayVal = isMulti && isGauge ? fleetAvg : isMulti ? podSum : (g.points.length > 0 ? resolveValue(g.points[g.points.length - 1]) : 0);
+ const values = g.points.map((p) => resolveValue(p));
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const avg = values.reduce((a, b) => a + b, 0) / Math.max(1, values.length);
+ const summaries = Array.from(podLatestMap.entries()).map(([pod, value]) => ({
+ pod,
+ value,
+ }));
+
+ return (
+
+ );
+ })}
+
+
+ )}
+
+ {/* Metric List Toolbar */}
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Filter metrics by name..."
+ className="h-9 w-52 sm:w-64 rounded-xl border border-white/10 bg-slate-900/50 pl-8 pr-8 text-xs text-white placeholder-slate-500 focus:border-sky-500/50 focus:outline-none"
+ />
+ {search && (
+ setSearch("")}
+ className="absolute right-2.5 text-slate-500 hover:text-white"
+ >
+
+
+ )}
+
+
+ {availableServices.length > 1 && (
+
setSelectedServiceFilter(e.target.value)}
+ className="h-9 rounded-xl border border-white/10 bg-slate-900/50 px-2.5 text-[11px] font-bold text-slate-300 outline-none focus:border-sky-500/50"
+ aria-label="Filter metrics by owning service"
+ >
+ All Services ({availableServices.length})
+ {availableServices.map((s) => (
+
+ {s}
+
+ ))}
+
+ )}
+
+ {/* Type filters */}
+
+ setFilterType("all")}
+ className={`rounded-lg px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider transition-all ${
+ filterType === "all"
+ ? "bg-sky-500/20 text-sky-400 shadow-sm"
+ : "text-slate-400 hover:text-white"
+ }`}
+ >
+ All ({metricList.length})
+
+ setFilterType("gauge")}
+ className={`rounded-lg px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider transition-all ${
+ filterType === "gauge"
+ ? "bg-cyan-500/20 text-cyan-300 shadow-sm"
+ : "text-slate-400 hover:text-white"
+ }`}
+ >
+ Gauges
+
+ setFilterType("sum")}
+ className={`rounded-lg px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider transition-all ${
+ filterType === "sum"
+ ? "bg-violet-500/20 text-violet-300 shadow-sm"
+ : "text-slate-400 hover:text-white"
+ }`}
+ >
+ Sums
+
+ setFilterType("histogram")}
+ className={`rounded-lg px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider transition-all ${
+ filterType === "histogram"
+ ? "bg-amber-500/20 text-amber-300 shadow-sm"
+ : "text-slate-400 hover:text-white"
+ }`}
+ >
+ Histograms
+
+
+
+
+
+ Showing {filteredMetrics.length} of {metricList.length} metrics
+
+
+
+ {/* Metrics Grid */}
+ {filteredMetrics.length === 0 ? (
+
+ No metrics match your search criteria.
+
+ ) : (
+
+ {filteredMetrics.map((item, index) => (
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/otel-ui/src/components/PatternsVisualizer.tsx b/otel-ui/src/components/PatternsVisualizer.tsx
new file mode 100644
index 0000000..eb0dd1f
--- /dev/null
+++ b/otel-ui/src/components/PatternsVisualizer.tsx
@@ -0,0 +1,76 @@
+import { useMemo, useState } from "react"
+import { GitBranch, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react"
+import { usePatterns } from "../hooks/usePatterns"
+import { formatRelativeTime } from "../lib/timeUtils"
+import type { Issue, SimilarIssue } from "../lib/types"
+
+const PatternIssues = ({ pattern, issues, similar, review }: {
+ pattern: { issueIds: string[] }
+ issues: Issue[]
+ similar: (id: string) => Promise
+ review: (id: string, otherId: string, decision: "same" | "different") => Promise
+}) => {
+ const [expanded, setExpanded] = useState(false)
+ const [related, setRelated] = useState>({})
+ const [busy, setBusy] = useState(null)
+ const members = pattern.issueIds.map((id) => issues.find((issue) => issue.id === id)).filter(Boolean) as Issue[]
+
+ const toggle = async (issue: Issue) => {
+ if (expanded) { setExpanded(false); return }
+ setExpanded(true)
+ if (!related[issue.id]) {
+ const suggestions = await similar(issue.id)
+ setRelated((prev) => ({ ...prev, [issue.id]: suggestions }))
+ }
+ }
+
+ const submitReview = async (issue: Issue, item: SimilarIssue, decision: "same" | "different") => {
+ const key = `${issue.id}:${item.issue.id}`
+ setBusy(key)
+ try {
+ await review(issue.id, item.issue.id, decision)
+ setRelated((prev) => ({ ...prev, [issue.id]: (prev[issue.id] ?? []).map((entry) => entry.issue.id === item.issue.id ? { ...entry, decision } : entry) }))
+ } finally { setBusy(null) }
+ }
+
+ return (
+
+
+ {members.map((issue) => void toggle(issue)} className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-left text-[10px] text-slate-300 hover:border-sky-400/40">{issue.title} ×{issue.count} )}
+
+ {expanded && members[0] &&
+
Deterministic suggestions for {members[0].title}
+ {(related[members[0].id] ?? []).map((item) => {
+ const key = `${members[0].id}:${item.issue.id}`
+ return
+ {item.issue.title} {Math.round(item.score * 100)}% {item.reasons.join(" · ")}
+ void submitReview(members[0], item, "same")} className="ml-auto inline-flex items-center gap-1 rounded border border-emerald-400/20 px-2 py-1 text-emerald-300 hover:bg-emerald-400/10"> same
+ void submitReview(members[0], item, "different")} className="inline-flex items-center gap-1 rounded border border-rose-400/20 px-2 py-1 text-rose-300 hover:bg-rose-400/10"> separate
+ {item.decision && reviewed: {item.decision} }
+
+ })}
+
}
+
+ )
+}
+
+export function PatternsVisualizer() {
+ const { patterns, issues, loading, error, refresh, similar, review } = usePatterns()
+ const [query, setQuery] = useState("")
+ const visible = useMemo(() => patterns.filter((pattern) => `${pattern.service} ${pattern.kind} ${pattern.signature}`.toLowerCase().includes(query.toLowerCase())), [patterns, query])
+
+ if (loading && patterns.length === 0) return Loading deterministic patterns…
+ return
+
+
Similarity Patterns
Exact issues stay separate; broader deterministic patterns are reviewable.
+
setQuery(event.target.value)} placeholder="Filter patterns…" className="ml-auto rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-slate-200 outline-none placeholder:text-slate-600 focus:border-sky-400/40" />
+
void refresh()} className="inline-flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white"> Refresh
+
+ {error &&
Failed to load patterns: {String(error)}
}
+ {visible.length === 0 ?
No deterministic patterns observed yet.
:
{visible.map((pattern) =>
+
{pattern.service} {pattern.kind} {pattern.issueCount} exact issues · {pattern.occurrenceCount} occurrences last seen {formatRelativeTime(pattern.lastSeen / 1e6)}
+
{pattern.signature.split("\u0000").join(" · ")}
+
+
)}
}
+
+}
diff --git a/otel-ui/src/components/RolloutsVisualizer.tsx b/otel-ui/src/components/RolloutsVisualizer.tsx
new file mode 100644
index 0000000..bda5344
--- /dev/null
+++ b/otel-ui/src/components/RolloutsVisualizer.tsx
@@ -0,0 +1,48 @@
+import { useState } from "react"
+import { Activity, CheckCircle2, CloudCog, RefreshCw, XCircle } from "lucide-react"
+import { useRollouts } from "../hooks/useRollouts"
+import { formatRelativeTime } from "../lib/timeUtils"
+import type { RolloutImpact } from "../lib/types"
+
+const phaseStyle: Record = {
+ completed: { className: "text-emerald-300 bg-emerald-400/10 border-emerald-400/20", Icon: CheckCircle2 },
+ failed: { className: "text-rose-300 bg-rose-400/10 border-rose-400/20", Icon: XCircle },
+ progressing: { className: "text-amber-300 bg-amber-400/10 border-amber-400/20", Icon: Activity },
+}
+
+export function RolloutsVisualizer() {
+ const { rollouts, loading, error, refresh, impact } = useRollouts()
+ const [selected, setSelected] = useState(null)
+ const [impactData, setImpactData] = useState>({})
+ const [impactError, setImpactError] = useState(null)
+
+ const select = async (id: string) => {
+ setSelected(selected === id ? null : id)
+ if (impactData[id]) return
+ try {
+ setImpactError(null)
+ const result = await impact(id)
+ setImpactData((previous) => ({ ...previous, [id]: result }))
+ }
+ catch (cause) { setImpactError(cause instanceof Error ? cause.message : String(cause)) }
+ }
+
+ if (loading && rollouts.length === 0) return Loading cluster rollouts…
+ return
+
+
Cluster Rollouts
Read-only Kubernetes deployment state with optional Prometheus impact data.
+
void refresh()} className="ml-auto inline-flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white"> Refresh
+
+ {error &&
Rollout watcher unavailable: {String(error)}
}
+ {impactError &&
Impact query failed: {impactError}
}
+ {rollouts.length === 0 ?
No rollout data. Enable the Kubernetes watcher and grant its read-only RBAC permissions.
:
{rollouts.map((rollout) => {
+ const status = phaseStyle[rollout.phase] ?? phaseStyle.progressing
+ const Icon = status.Icon
+ const data = impactData[rollout.id]
+ return
+
void select(rollout.id)} className="w-full text-left"> {rollout.phase}{rollout.namespace}/{rollout.workload} revision {rollout.revision || "unknown"} {formatRelativeTime(rollout.lastSeen / 1e6)}
release: {rollout.release || "unlabeled"} image: {rollout.image || "unknown"} replicas: {rollout.availableReplicas}/{rollout.desiredReplicas} ready reason: {rollout.reason || "unknown"}
+ {selected === rollout.id &&
Started {formatRelativeTime(rollout.startedAt / 1e6)}{rollout.completedAt ? ` · completed ${formatRelativeTime(rollout.completedAt / 1e6)}` : ""}
{rollout.message || "No rollout message reported."}
{data &&
Prometheus error-rate points
{!data.configured ?
Prometheus is not configured.
: data.points.length === 0 ?
No matching Prometheus series.
:
{data.points.slice(-12).map((point) => {new Date(point.timestamp * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}: {point.value} )}
}
}
}
+
+ })}
}
+
+}
diff --git a/otel-ui/src/components/SearchAndWatcher.tsx b/otel-ui/src/components/SearchAndWatcher.tsx
new file mode 100644
index 0000000..5d90bac
--- /dev/null
+++ b/otel-ui/src/components/SearchAndWatcher.tsx
@@ -0,0 +1,504 @@
+import React, { useState, useRef } from "react";
+import {
+ Search,
+ Radar,
+ Play,
+ Square,
+ Copy,
+ Check,
+ ExternalLink,
+} from "lucide-react";
+import { authFetch } from "../lib/auth";
+import { useObservabilityData } from "../hooks/useObservabilityData";
+
+interface SearchResultEntry {
+
+ type: "traces" | "metrics" | "logs" | string;
+ timestamp: number;
+ data: any;
+}
+
+export const SearchAndWatcher: React.FC = () => {
+ const { setActiveTraceId, setActiveTab } = useObservabilityData();
+
+ // Search state
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchType, setSearchType] = useState("");
+ const [searchLimit, setSearchLimit] = useState(100);
+ const [searchTimeRange, setSearchTimeRange] = useState("24h");
+ const [searchResults, setSearchResults] = useState(null);
+ const [searchLoading, setSearchLoading] = useState(false);
+ const [searchError, setSearchError] = useState(null);
+
+ // Watcher state
+ const [watchQuery, setWatchQuery] = useState("");
+ const [watchType, setWatchType] = useState("");
+ const [watchTimeout, setWatchTimeout] = useState(30);
+ const [isWatching, setIsWatching] = useState(false);
+ const [caughtSignal, setCaughtSignal] = useState(null);
+ const [watchStatus, setWatchStatus] = useState(null);
+ const abortControllerRef = useRef(null);
+
+ // UI helpers
+ const [copiedId, setCopiedId] = useState(null);
+ const [expandedIndex, setExpandedIndex] = useState(null);
+
+ const copyToClipboard = (text: string, id: string) => {
+ navigator.clipboard.writeText(text);
+ setCopiedId(id);
+ setTimeout(() => setCopiedId(null), 1500);
+ };
+
+ const handleRunSearch = async (e?: React.FormEvent) => {
+ if (e) e.preventDefault();
+ setSearchLoading(true);
+ setSearchError(null);
+
+ try {
+ const params = new URLSearchParams();
+ if (searchQuery.trim()) params.set("q", searchQuery.trim());
+ if (searchType) params.set("type", searchType);
+ params.set("limit", String(searchLimit));
+
+ if (searchTimeRange !== "all") {
+ const msMap: Record = {
+ "1h": 60 * 60 * 1000,
+ "6h": 6 * 60 * 60 * 1000,
+ "24h": 24 * 60 * 60 * 1000,
+ "7d": 7 * 24 * 60 * 60 * 1000,
+ };
+ const ms = msMap[searchTimeRange] || 24 * 60 * 60 * 1000;
+ const sinceNano = BigInt(Date.now() - ms) * 1_000_000n;
+ params.set("since", sinceNano.toString());
+ }
+
+ const res = await authFetch(`/api/history/search?${params.toString()}`);
+ if (!res.ok) throw new Error(`Search failed: HTTP ${res.status}`);
+ const data = await res.json();
+ setSearchResults(Array.isArray(data) ? data : []);
+ } catch (err) {
+ setSearchError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setSearchLoading(false);
+ }
+ };
+
+ const startWatcher = async () => {
+ if (isWatching) return;
+ setIsWatching(true);
+ setCaughtSignal(null);
+ setWatchStatus("Trap armed. Waiting for telemetry match...");
+
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ try {
+ const params = new URLSearchParams();
+ if (watchQuery.trim()) params.set("q", watchQuery.trim());
+ if (watchType) params.set("type", watchType);
+ params.set("timeout", String(watchTimeout));
+
+ const res = await authFetch(`/api/history/wait?${params.toString()}`, {
+ signal: controller.signal,
+ });
+
+ if (!res.ok) {
+ if (res.status === 504) {
+ setWatchStatus(`Timeout reached (${watchTimeout}s) without match.`);
+ } else {
+ setWatchStatus(`Error: HTTP ${res.status}`);
+ }
+ return;
+ }
+
+ const signal = await res.json();
+ setCaughtSignal(signal);
+ setWatchStatus("Target signal trapped successfully!");
+ } catch (err: any) {
+ if (err.name === "AbortError") {
+ setWatchStatus("Trap disarmed by user.");
+ } else {
+ setWatchStatus(`Watcher error: ${err.message || String(err)}`);
+ }
+ } finally {
+ setIsWatching(false);
+ abortControllerRef.current = null;
+ }
+ };
+
+ const cancelWatcher = () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ setIsWatching(false);
+ };
+
+ // Helper to extract trace ID from an entry
+ const extractTraceId = (entry: SearchResultEntry): string | null => {
+ const raw = JSON.stringify(entry.data);
+ const match = raw.match(/"traceId":"([a-f0-9]{16,32})"/i);
+ return match ? match[1] : null;
+ };
+
+ return (
+
+ {/* Top Banner */}
+
+
+
+
+
+
+
+
+ Archive Search & Live Signal Trap
+
+
+
+ Expose deep telemetry data beyond the browser buffer. Query full ClickHouse & persistent storage logs/traces, or arm an OTLP signal trap to catch anomalies as they hit the server.
+
+
+
+
+
+ {/* SECTION 1: Live Signal Trap */}
+
+
+
+
+
+ Live Signal Trap (/api/history/wait)
+
+
+
+ Long-poll subscriber for active debugging
+
+
+
+
+
+
+ Target Keyword / Pattern / Trace ID
+
+
+ setWatchQuery(e.target.value)}
+ placeholder="e.g. 'error', '500', 'db_timeout', or service name..."
+ disabled={isWatching}
+ className="w-full h-9 rounded-xl border border-white/10 bg-black/40 px-3 text-xs text-white placeholder-slate-500 focus:border-violet-500/50 focus:outline-none disabled:opacity-50 font-mono"
+ />
+
+
+
+
+
+ Signal Type
+
+ setWatchType(e.target.value)}
+ disabled={isWatching}
+ className="w-full h-9 rounded-xl border border-white/10 bg-black/40 px-3 text-xs text-white focus:border-violet-500/50 focus:outline-none disabled:opacity-50 font-mono"
+ >
+ Any Telemetry
+ Traces Only
+ Logs Only
+ Metrics Only
+
+
+
+
+
+
+ Timeout
+
+ setWatchTimeout(Number(e.target.value))}
+ disabled={isWatching}
+ className="w-full h-9 rounded-xl border border-white/10 bg-black/40 px-3 text-xs text-white focus:border-violet-500/50 focus:outline-none disabled:opacity-50 font-mono"
+ >
+ 15 seconds
+ 30 seconds
+ 60 seconds
+
+
+
+ {isWatching ? (
+
+
+ Cancel
+
+ ) : (
+
+
+ Arm Trap
+
+ )}
+
+
+
+ {/* Watcher Status or Live Trigger Output */}
+ {watchStatus && (
+
+ )}
+
+ {/* Captured Signal Preview */}
+ {caughtSignal && (
+
+
+
+
+ {caughtSignal.type}
+
+
+ Captured at {new Date(caughtSignal.timestamp / 1_000_000).toLocaleTimeString()}
+
+
+
+ {extractTraceId(caughtSignal) && (
+
{
+ const traceId = extractTraceId(caughtSignal);
+ if (traceId) {
+ setActiveTraceId(traceId);
+ setActiveTab("tab1");
+ }
+ }}
+ className="inline-flex items-center gap-1.5 rounded-lg bg-sky-500 px-3 py-1 text-[11px] font-bold text-slate-950 hover:bg-sky-400 transition-colors"
+ >
+
+ Jump to Captured Trace
+
+ )}
+
+
+
+ {JSON.stringify(caughtSignal.data, null, 2)}
+
+
+ )}
+
+
+ {/* SECTION 2: Persistent Storage Search */}
+
+
+
+
+
+ Query Persistent Telemetry Store (/api/history/search)
+
+
+
+ Searches full database history beyond active memory
+
+
+
+
+
+ {searchError && (
+
+ {searchError}
+
+ )}
+
+ {/* Results List */}
+ {searchResults !== null && (
+
+
+ Found {searchResults.length} persistent storage matches
+
+
+ {searchResults.length === 0 ? (
+
+ No telemetry entries matched your search criteria in the selected time range.
+
+ ) : (
+
+ {searchResults.map((entry, idx) => {
+ const traceId = extractTraceId(entry);
+ const isExpanded = expandedIndex === idx;
+ const timeFormatted = entry.timestamp
+ ? new Date(entry.timestamp / 1_000_000).toLocaleString()
+ : "—";
+
+ return (
+
+
setExpandedIndex(isExpanded ? null : idx)}
+ className="flex cursor-pointer items-center justify-between gap-3"
+ >
+
+
+ {entry.type}
+
+ {timeFormatted}
+ {traceId && (
+
+ trace:{traceId.slice(0, 8)}...
+
+ )}
+
+
+
+ {traceId && (
+ {
+ e.stopPropagation();
+ setActiveTraceId(traceId);
+ setActiveTab("tab1");
+ }}
+ className="rounded bg-sky-500/10 border border-sky-500/30 px-2 py-0.5 text-[10px] text-sky-300 hover:bg-sky-500/20 transition-colors"
+ >
+ Open Trace
+
+ )}
+ {
+ e.stopPropagation();
+ copyToClipboard(JSON.stringify(entry.data, null, 2), `res-${idx}`);
+ }}
+ className="p-1 text-slate-500 hover:text-white transition-colors"
+ title="Copy JSON"
+ >
+ {copiedId === `res-${idx}` ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {isExpanded && (
+
+ {JSON.stringify(entry.data, null, 2)}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ )}
+
+
+ );
+};
+
+export default SearchAndWatcher;
diff --git a/otel-ui/src/components/ServiceGraph.tsx b/otel-ui/src/components/ServiceGraph.tsx
new file mode 100644
index 0000000..85df435
--- /dev/null
+++ b/otel-ui/src/components/ServiceGraph.tsx
@@ -0,0 +1,495 @@
+import { useMemo, useState } from "react";
+import { Layers } from "lucide-react";
+import { formatDuration } from "../lib/timeUtils";
+
+export interface GraphSpan {
+ spanId: string;
+ parentSpanId?: string;
+ service: string;
+ duration: number;
+ hasError?: boolean;
+}
+
+interface ServiceNode {
+ id: string;
+ name: string;
+ spanCount: number;
+ errorCount: number;
+ totalDuration: number;
+ layer: number;
+ x: number;
+ y: number;
+}
+
+interface ServiceEdge {
+ id: string;
+ source: string;
+ target: string;
+ callCount: number;
+ avgDuration: number;
+ hasError: boolean;
+}
+
+interface ServiceGraphProps {
+ spans: GraphSpan[];
+ onSelectService?: (serviceName: string | null) => void;
+ selectedService?: string | null;
+}
+
+export const ServiceGraph = ({
+ spans,
+ onSelectService,
+ selectedService,
+}: ServiceGraphProps) => {
+ const [hoveredNode, setHoveredNode] = useState(null);
+
+ const { nodes, edges, width, height } = useMemo(() => {
+ // 1. Map spans by ID for parent lookup
+ const spanMap = new Map();
+ spans.forEach((s) => spanMap.set(s.spanId, s));
+
+ // 2. Aggregate service metrics
+ const serviceStats = new Map<
+ string,
+ { spanCount: number; errorCount: number; totalDuration: number }
+ >();
+
+ spans.forEach((span) => {
+ const current = serviceStats.get(span.service) || {
+ spanCount: 0,
+ errorCount: 0,
+ totalDuration: 0,
+ };
+ current.spanCount += 1;
+ if (span.hasError) current.errorCount += 1;
+ current.totalDuration += span.duration;
+ serviceStats.set(span.service, current);
+ });
+
+ // 3. Aggregate cross-service edges
+ const edgeMap = new Map<
+ string,
+ { source: string; target: string; count: number; totalDur: number; errors: number }
+ >();
+
+ spans.forEach((span) => {
+ if (span.parentSpanId && spanMap.has(span.parentSpanId)) {
+ const parentSpan = spanMap.get(span.parentSpanId)!;
+ if (parentSpan.service !== span.service) {
+ const edgeKey = `${parentSpan.service}->${span.service}`;
+ const currentEdge = edgeMap.get(edgeKey) || {
+ source: parentSpan.service,
+ target: span.service,
+ count: 0,
+ totalDur: 0,
+ errors: 0,
+ };
+ currentEdge.count += 1;
+ currentEdge.totalDur += span.duration;
+ if (span.hasError) currentEdge.errors += 1;
+ edgeMap.set(edgeKey, currentEdge);
+ }
+ }
+ });
+
+ const uniqueServices = Array.from(serviceStats.keys());
+
+ // 4. Determine layer depth for each service (Topological ordering)
+ const inDegree = new Map();
+ uniqueServices.forEach((s) => inDegree.set(s, 0));
+ edgeMap.forEach((edge) => {
+ inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1);
+ });
+
+ const layers = new Map();
+ // Roots have 0 in-degree
+ const queue: string[] = [];
+ uniqueServices.forEach((s) => {
+ if ((inDegree.get(s) || 0) === 0) {
+ layers.set(s, 0);
+ queue.push(s);
+ }
+ });
+
+ // If there's a cycle or no zero-in-degree root, fallback to first service
+ if (queue.length === 0 && uniqueServices.length > 0) {
+ layers.set(uniqueServices[0], 0);
+ queue.push(uniqueServices[0]);
+ }
+
+ while (queue.length > 0) {
+ const current = queue.shift()!;
+ const currentLayer = layers.get(current) || 0;
+ edgeMap.forEach((edge) => {
+ if (edge.source === current) {
+ const targetLayer = Math.max(layers.get(edge.target) || 0, currentLayer + 1);
+ layers.set(edge.target, targetLayer);
+ if (!queue.includes(edge.target)) {
+ queue.push(edge.target);
+ }
+ }
+ });
+ }
+
+ // Ensure all services have a layer
+ uniqueServices.forEach((s) => {
+ if (!layers.has(s)) layers.set(s, 0);
+ });
+
+ // Group services by layer
+ const layerGroups = new Map();
+ uniqueServices.forEach((s) => {
+ const l = layers.get(s) || 0;
+ const group = layerGroups.get(l) || [];
+ group.push(s);
+ layerGroups.set(l, group);
+ });
+
+ const totalLayers = Math.max(1, layerGroups.size);
+ const nodeWidth = 200;
+ const nodeHeight = 84;
+ const xGap = 160;
+ const yGap = 36;
+
+ let maxNodesInLayer = 1;
+ layerGroups.forEach((group) => {
+ if (group.length > maxNodesInLayer) maxNodesInLayer = group.length;
+ });
+
+ const canvasWidth = Math.max(700, totalLayers * (nodeWidth + xGap));
+ const canvasHeight = Math.max(300, maxNodesInLayer * (nodeHeight + yGap) + 60);
+
+ // Compute positions
+ const computedNodes: ServiceNode[] = [];
+ const nodePosMap = new Map();
+
+ Array.from(layerGroups.keys())
+ .sort((a, b) => a - b)
+ .forEach((layerIdx) => {
+ const group = layerGroups.get(layerIdx) || [];
+ const layerX = 40 + layerIdx * (nodeWidth + xGap);
+ const groupHeight = group.length * nodeHeight + (group.length - 1) * yGap;
+ const startY = Math.max(40, (canvasHeight - groupHeight) / 2);
+
+ group.forEach((serviceName, idxInGroup) => {
+ const layerY = startY + idxInGroup * (nodeHeight + yGap);
+ const stats = serviceStats.get(serviceName)!;
+ computedNodes.push({
+ id: serviceName,
+ name: serviceName,
+ spanCount: stats.spanCount,
+ errorCount: stats.errorCount,
+ totalDuration: stats.totalDuration,
+ layer: layerIdx,
+ x: layerX,
+ y: layerY,
+ });
+ nodePosMap.set(serviceName, { x: layerX, y: layerY });
+ });
+ });
+
+ // Computed edges with coordinates
+ const computedEdges: ServiceEdge[] = [];
+ edgeMap.forEach((edge, key) => {
+ computedEdges.push({
+ id: key,
+ source: edge.source,
+ target: edge.target,
+ callCount: edge.count,
+ avgDuration: edge.totalDur / edge.count,
+ hasError: edge.errors > 0,
+ });
+ });
+
+ return {
+ nodes: computedNodes,
+ edges: computedEdges,
+ width: canvasWidth,
+ height: canvasHeight,
+ };
+ }, [spans]);
+
+ if (nodes.length === 0) {
+ return (
+
+ No service nodes available to visualize.
+
+ );
+ }
+
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
+
+ return (
+
+
+
+
+
+ Service Call Graph ({nodes.length} Services, {edges.length} Cross-Service Links)
+
+
+
+
+ Healthy
+
+
+ Errors
+
+ {selectedService && (
+ onSelectService?.(null)}
+ className="ml-2 rounded-lg bg-sky-500/20 px-2 py-0.5 text-sky-300 hover:bg-sky-500/30 transition-all font-semibold"
+ >
+ Clear filter ({selectedService})
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Render Connecting Edges */}
+ {edges.map((edge) => {
+ const src = nodeMap.get(edge.source);
+ const tgt = nodeMap.get(edge.target);
+ if (!src || !tgt) return null;
+
+ const startX = src.x + 200;
+ const startY = src.y + 42;
+ const endX = tgt.x;
+ const endY = tgt.y + 42;
+
+ const dx = endX - startX;
+ const cpx1 = startX + dx * 0.45;
+ const cpy1 = startY;
+ const cpx2 = endX - dx * 0.45;
+ const cpy2 = endY;
+
+ const pathD = `M ${startX} ${startY} C ${cpx1} ${cpy1}, ${cpx2} ${cpy2}, ${endX} ${endY}`;
+ const midX = (startX + endX) / 2;
+ const midY = (startY + endY) / 2;
+
+ const isEdgeHighlighted =
+ hoveredNode === edge.source ||
+ hoveredNode === edge.target ||
+ selectedService === edge.source ||
+ selectedService === edge.target;
+
+ return (
+
+ {/* Background path for hit testing */}
+
+ {/* Visible animated curve */}
+
+ {/* Edge Call Badge */}
+
+
+
+ {edge.callCount}x · {formatDuration(edge.avgDuration)}
+
+
+
+ );
+ })}
+
+ {/* Render Service Nodes */}
+ {nodes.map((node) => {
+ const isSelected = selectedService === node.name;
+ const isHovered = hoveredNode === node.name;
+ const hasError = node.errorCount > 0;
+
+ return (
+ onSelectService?.(isSelected ? null : node.name)}
+ onMouseEnter={() => setHoveredNode(node.name)}
+ onMouseLeave={() => setHoveredNode(null)}
+ className="cursor-pointer"
+ >
+ {/* Node Box */}
+
+
+ {/* Left Accent Stripe */}
+
+
+ {/* Service Name Header */}
+
+
+ {node.name.length > 20 ? `${node.name.slice(0, 18)}...` : node.name}
+
+
+
+ {/* Spans Count & Latency Pills */}
+
+
+ Spans: {node.spanCount}
+
+
+ Time: {formatDuration(node.totalDuration)}
+
+
+
+ {/* Status Badges */}
+
+ {hasError ? (
+
+
+
+ {node.errorCount} Error{node.errorCount > 1 ? "s" : ""}
+
+
+ ) : (
+
+
+
+ Healthy
+
+
+ )}
+ {isSelected && (
+
+
+
+ Active
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+ {nodes.length === 1 && (
+
+ This trace was executed inside a single service ({nodes[0].name}). No cross-service network calls were made.
+
+ )}
+
+ );
+};
diff --git a/otel-ui/src/components/SetupPanel.tsx b/otel-ui/src/components/SetupPanel.tsx
new file mode 100644
index 0000000..d1672af
--- /dev/null
+++ b/otel-ui/src/components/SetupPanel.tsx
@@ -0,0 +1,112 @@
+import { useState } from "react"
+import { Check, Copy, ExternalLink, FileText, Globe } from "lucide-react"
+import { useObservabilityData } from "../hooks/useObservabilityData"
+
+const scriptSnippet = `
+`
+
+const CopyButton = ({ text }: { text: string }) => {
+ const [copied, setCopied] = useState(false)
+ return (
+ {
+ try {
+ await navigator.clipboard.writeText(text)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ } catch {
+ // clipboard API unavailable; nothing to fall back to here
+ }
+ }}
+ className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:text-white hover:border-white/20 transition-all"
+ >
+ {copied ? : }
+ {copied ? "Copied" : "Copy"}
+
+ )
+}
+
+export const SetupPanel = () => {
+ const { setActiveTab } = useObservabilityData()
+ return (
+
+
+
+
+
+ Browser instrumentation (otel-rum.js)
+
+
+
+
+ Drop this into any page to capture uncaught errors, unhandled rejections and failed
+ resource loads as OTLP logs. Errors show up here under the{" "}
+ Issues tab tagged{" "}
+ Frontend Error , and correlate with
+ backend traces via traceHeaders().
+
+
+ {scriptSnippet}
+
+
+
+
+ View raw script at /static/otel-rum.js
+
+
•
+
setActiveTab("tab7")}
+ className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-indigo-400 hover:text-indigo-300"
+ >
+
+ Open Frontend & RUM Explorer →
+
+
+
+
+
+
+
+ Agent instructions
+
+
+ A single doc an AI coding agent can fetch before touching this API - endpoints, query
+ params and response shapes for traces, logs, metrics and issues.
+
+
+
+ Open /agents_example.md
+
+
+
+ )
+}
+
+export default SetupPanel
diff --git a/otel-ui/src/components/Sidebar.tsx b/otel-ui/src/components/Sidebar.tsx
new file mode 100644
index 0000000..1acae66
--- /dev/null
+++ b/otel-ui/src/components/Sidebar.tsx
@@ -0,0 +1,344 @@
+import React from "react";
+import { useObservabilityData } from "../hooks/useObservabilityData";
+import { useIssues } from "../hooks/useIssues";
+import { useStatsAvailability } from "../hooks/useStats";
+
+export const Sidebar: React.FC = () => {
+ const {
+ activeTab,
+ setActiveTab,
+ traces,
+ logs,
+ metrics,
+ isConnected,
+ uniqueServices,
+ serviceHealth,
+ selectedServices,
+ toggleService,
+ resetFilters,
+ } = useObservabilityData();
+
+ const { issues } = useIssues();
+ const statsAvailable = useStatsAvailability();
+
+ // Active open issues count
+ const openIssuesCount = React.useMemo(() => {
+ return issues.filter((i) => !i.status || i.status === "open").length;
+ }, [issues]);
+
+ const errorLogCount = React.useMemo(() => {
+ let errs = 0;
+ logs.forEach((log) => {
+ (log.scopeLogs ?? []).forEach((sl) => {
+ (sl.logRecords ?? []).forEach((r) => {
+ if ((r.severityNumber ?? 0) >= 17) errs += 1;
+ });
+ });
+ });
+ return errs;
+ }, [logs]);
+
+ const totalSpans = React.useMemo(() => {
+ let count = 0;
+ traces.forEach((t) => {
+ (t.resourceSpans ?? []).forEach((rs) => {
+ (rs.scopeSpans ?? []).forEach((ss) => {
+ count += (ss.spans ?? []).length;
+ });
+ });
+ });
+ return count;
+ }, [traces]);
+
+ const { frontendCount, frontendErrorCount } = React.useMemo(() => {
+ let count = 0;
+ let errors = 0;
+ logs.forEach((log) => {
+ (log.scopeLogs ?? []).forEach((sl) => {
+ (sl.logRecords ?? []).forEach((r) => {
+ const attrs = r.attributes ?? [];
+ const source = attrs.find((a) => a.key === "source")?.value.stringValue;
+ const isRum = sl.scope?.name === "otel-rum";
+ const vital = attrs.find((a) => a.key === "rum.vital")?.value.stringValue;
+ const isFrontend = source === "frontend" || isRum || Boolean(vital);
+ if (isFrontend) {
+ count++;
+ if ((r.severityNumber ?? 0) >= 17 || attrs.some((a) => a.key === "exception.type")) {
+ errors++;
+ }
+ }
+ });
+ });
+ });
+ return { frontendCount: count, frontendErrorCount: errors };
+ }, [logs]);
+
+ const navItems = [
+ {
+ tab: "tab0",
+ label: "Issues & Anomalies",
+ icon: "error",
+ badge: openIssuesCount > 0 ? `${openIssuesCount} active` : `${issues.length} total`,
+ badgeType: openIssuesCount > 0 ? ("error" as const) : ("outline" as const),
+ },
+ {
+ tab: "tab8",
+ label: "Similarity Patterns",
+ icon: "hub",
+ badge: undefined,
+ badgeType: "outline" as const,
+ },
+ {
+ tab: "tab9",
+ label: "Cluster Rollouts",
+ icon: "cloud_sync",
+ badge: undefined,
+ badgeType: "outline" as const,
+ },
+ {
+ tab: "tab1",
+ label: "Traces & Spans",
+ icon: "account_tree",
+ badge: totalSpans > 0 ? `${totalSpans} spans` : `${traces.length} traces`,
+ badgeType: totalSpans > 0 ? ("secondary" as const) : ("outline" as const),
+ },
+ {
+ tab: "tab3",
+ label: "Live Log Stream",
+ icon: "terminal",
+ badge: errorLogCount > 0 ? `${errorLogCount} err` : `${logs.length} logs`,
+ badgeType: errorLogCount > 0 ? ("error" as const) : ("secondary" as const),
+ },
+ {
+ tab: "tab2",
+ label: "Metrics & APM",
+ icon: "query_stats",
+ badge: `${metrics.length} metrics`,
+ badgeType: "outline" as const,
+ },
+ {
+ tab: "tab7",
+ label: "Frontend & RUM",
+ icon: "devices",
+ badge:
+ frontendCount > 0
+ ? frontendErrorCount > 0
+ ? `${frontendErrorCount} err`
+ : `${frontendCount} sig`
+ : "RUM",
+ badgeType: frontendErrorCount > 0 ? ("error" as const) : ("secondary" as const),
+ },
+ ...(statsAvailable
+ ? [
+ {
+ tab: "tab5",
+ label: "Historical Trends",
+ icon: "trending_up",
+ badge: "ClickHouse",
+ badgeType: "outline" as const,
+ },
+ ]
+ : []),
+ {
+ tab: "tab6",
+ label: "Archive & Trap",
+ icon: "radar",
+ badge: "Deep Query",
+ badgeType: "secondary" as const,
+ },
+ {
+ tab: "tab4",
+ label: "Setup & Agent API",
+ icon: "build",
+ badge: "RUM / Doc",
+ badgeType: "outline" as const,
+ },
+ ];
+
+ return (
+
+ );
+};
diff --git a/otel-ui/src/components/StructuredLogTerminal.tsx b/otel-ui/src/components/StructuredLogTerminal.tsx
new file mode 100644
index 0000000..3710e41
--- /dev/null
+++ b/otel-ui/src/components/StructuredLogTerminal.tsx
@@ -0,0 +1,582 @@
+import React, { useMemo, useState } from "react";
+import type { LogEntry, LogRecord, OtelAttribute } from "../lib/types";
+import { useObservabilityData } from "../hooks/useObservabilityData";
+import { formatAttributeValue, getServiceName } from "../lib/otel-utils";
+
+interface FlattenedRecord extends LogRecord {
+ scope?: string;
+ resourceAttributes: OtelAttribute[];
+ serviceName: string;
+}
+
+export const StructuredLogTerminal: React.FC<{ logs: LogEntry[] }> = ({ logs }) => {
+ const { setActiveTraceId, setActiveSpanId, setActiveTab, selectedServices, toggleService } =
+ useObservabilityData();
+
+ const [expandedRowId, setExpandedRowId] = useState(null);
+ const [streamFilter, setStreamFilter] = useState("");
+ const [wrapText, setWrapText] = useState(false);
+ const [cleared, setCleared] = useState(false);
+
+ // Severity filters
+ const [selectedSeverities, setSelectedSeverities] = useState>({
+ FATAL: true,
+ ERROR: true,
+ WARN: true,
+ INFO: true,
+ DEBUG: false,
+ });
+
+ const toggleSeverity = (sev: string) => {
+ setSelectedSeverities((prev) => ({ ...prev, [sev]: !prev[sev] }));
+ };
+
+ // Flatten all log records
+ const allRecords: FlattenedRecord[] = useMemo(() => {
+ if (cleared) return [];
+ return logs.flatMap((log) =>
+ (log.scopeLogs ?? []).flatMap((scopeLog) =>
+ (scopeLog.logRecords ?? []).map((record) => {
+ const serviceName = getServiceName(
+ log.resource?.attributes ?? [],
+ record.attributes ?? [],
+ );
+ return {
+ ...record,
+ scope: scopeLog.scope?.name,
+ resourceAttributes: log.resource?.attributes ?? [],
+ serviceName,
+ };
+ }),
+ ),
+ );
+ }, [logs, cleared]);
+
+ // Counts by severity
+ const severityCounts = useMemo(() => {
+ const counts = { FATAL: 0, ERROR: 0, WARN: 0, INFO: 0, DEBUG: 0 };
+ allRecords.forEach((r) => {
+ const num = r.severityNumber ?? 0;
+ if (num >= 21) counts.FATAL++;
+ else if (num >= 17) counts.ERROR++;
+ else if (num >= 13) counts.WARN++;
+ else if (num >= 9) counts.INFO++;
+ else counts.DEBUG++;
+ });
+ return counts;
+ }, [allRecords]);
+
+ // Service facet counts
+ const serviceCounts = useMemo(() => {
+ const counts = new Map();
+ allRecords.forEach((r) => {
+ counts.set(r.serviceName, (counts.get(r.serviceName) || 0) + 1);
+ });
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
+ }, [allRecords]);
+
+ // Real diagnostic heuristic calculated from actual logs
+ const errorHeuristic = useMemo(() => {
+ const errorRecords = allRecords.filter((r) => (r.severityNumber ?? 0) >= 17);
+ if (errorRecords.length === 0) {
+ return {
+ text: serviceCounts.length > 0
+ ? `All ${serviceCounts.length} active service(s) nominal. 0 error logs recorded in current buffer.`
+ : "No log signals received yet. Waiting for OTLP logs...",
+ hasErrors: false,
+ };
+ }
+
+ const errByService: Record = {};
+ errorRecords.forEach((r) => {
+ errByService[r.serviceName] = (errByService[r.serviceName] || 0) + 1;
+ });
+
+ const sorted = Object.entries(errByService).sort((a, b) => b[1] - a[1]);
+ const topService = sorted[0]?.[0] || "unknown";
+ const topCount = sorted[0]?.[1] || 0;
+ const pct = Math.round((topCount / errorRecords.length) * 100);
+
+ return {
+ text: `${pct}% of current ERROR logs (${topCount} of ${errorRecords.length}) originated in "${topService}".`,
+ hasErrors: true,
+ };
+ }, [allRecords, serviceCounts.length]);
+
+ // Filter records based on selected severities, services, and local stream search
+ const visibleRecords = useMemo(() => {
+ return allRecords.filter((r) => {
+ const num = r.severityNumber ?? 0;
+ let sevKey = "DEBUG";
+ if (num >= 21) sevKey = "FATAL";
+ else if (num >= 17) sevKey = "ERROR";
+ else if (num >= 13) sevKey = "WARN";
+ else if (num >= 9) sevKey = "INFO";
+
+ if (!selectedSeverities[sevKey]) return false;
+
+ if (streamFilter.trim()) {
+ const query = streamFilter.toLowerCase().trim();
+ const msg = (r.body?.stringValue ?? "").toLowerCase();
+ const srv = r.serviceName.toLowerCase();
+ const trace = (r.traceId ?? "").toLowerCase();
+ const attrs = (r.attributes ?? []).some(
+ (a) =>
+ a.key.toLowerCase().includes(query) ||
+ formatAttributeValue(a.value).toLowerCase().includes(query),
+ );
+ if (!msg.includes(query) && !srv.includes(query) && !trace.includes(query) && !attrs) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+ }, [allRecords, selectedSeverities, streamFilter]);
+
+ const copyLogStream = () => {
+ if (visibleRecords.length === 0) return;
+ const excerpt = visibleRecords
+ .slice(0, 50)
+ .map((r) => {
+ const time = new Date(Number(BigInt(r.timeUnixNano || "0") / 1_000_000n)).toISOString();
+ return `[${time}] ${r.severityText || "LOG"} ${r.serviceName}: ${r.body?.stringValue ?? ""}`;
+ })
+ .join("\n");
+ navigator.clipboard.writeText(excerpt);
+ alert("Copied visible log excerpt to clipboard.");
+ };
+
+ const navigateToTraceSpan = (traceId: string, spanId?: string) => {
+ setActiveTraceId(traceId);
+ if (spanId) setActiveSpanId(spanId);
+ setActiveTab("tab1");
+ };
+
+ const formatIsoTimestamp = (nanoStr?: string) => {
+ if (!nanoStr) return "—";
+ const millis = Number(BigInt(nanoStr) / 1_000_000n);
+ return new Date(millis).toISOString();
+ };
+
+ return (
+
+ {/* LEFT PANEL: Faceted Filter Breakdown (3 Cols) */}
+
+ {/* Severity Breakdown Card */}
+
+
+
+ Severity Breakdown
+
+ {allRecords.length} Total
+
+
+
+
+
+ {/* Service Facet Card */}
+
+
+
+ Service Name
+
+ filter_alt
+
+
+
+ {serviceCounts.slice(0, 6).map(([serviceName, count]) => {
+ const isSelected = selectedServices.has(serviceName);
+ return (
+
toggleService(serviceName)}
+ className={`flex items-center justify-between px-2.5 py-1.5 rounded-lg cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-surface-container text-primary-container font-semibold"
+ : "hover:bg-surface-container text-on-surface-variant"
+ }`}
+ >
+
+
+ {serviceName}
+
+
{count}
+
+ );
+ })}
+ {serviceCounts.length === 0 && (
+
No services detected
+ )}
+
+
+
+ {/* AI Root Cause Diagnostic Hint Box */}
+
+
+ psychology
+
+ Diagnostic Heuristic
+
+
+
+ {errorHeuristic.text}
+
+
+ {
+ setSelectedSeverities({ FATAL: true, ERROR: true, WARN: false, INFO: false, DEBUG: false });
+ }}
+ className="font-mono text-[10px] text-primary-container hover:underline uppercase flex items-center gap-1 font-semibold"
+ >
+ Filter Only Error Signatures
+ arrow_forward
+
+
+
+
+
+ {/* RIGHT PANEL: High-Density Structured Live Log Stream (9 Cols) */}
+
+ {/* Stream Controls Bar */}
+
+
+
+
+ filter_list
+
+ setStreamFilter(e.target.value)}
+ placeholder="Filter current buffer (e.g. 'timeout', 'status:504')..."
+ className="w-full h-7 pl-8 pr-3 rounded bg-surface-container text-on-surface font-mono text-xs placeholder:text-outline focus:outline-none focus:bg-surface-container-high transition-colors border border-surface-container/50"
+ />
+
+
+
+
+
+ setWrapText(e.target.checked)}
+ className="rounded bg-surface-container border-outline text-primary-container focus:ring-0"
+ />
+ Wrap Text
+
+
+
+
+
setCleared(true)}
+ className="text-outline hover:text-on-surface p-1 rounded hover:bg-surface-container transition-colors"
+ title="Clear Buffer"
+ >
+ clear_all
+
+
+
+ content_copy
+
+
+
+
+ {/* Log Terminal Headers */}
+
+
Timestamp (UTC)
+
Level
+
Service & Trace
+
Log Message & Structured Payload
+
+
+ {/* Realtime Log Entries List */}
+
+ {visibleRecords.length === 0 ? (
+
+ {cleared
+ ? "Stream buffer cleared. Waiting for new telemetry records..."
+ : "No log records match the current filters."}
+
+ ) : (
+ visibleRecords.map((record) => {
+ const num = record.severityNumber ?? 0;
+ let sevLabel = record.severityText?.toUpperCase() || "INFO";
+ let sevClass = "bg-surface-container-high text-secondary";
+
+ if (num >= 21) {
+ sevLabel = "FATAL";
+ sevClass = "bg-error text-on-error font-extrabold";
+ } else if (num >= 17) {
+ sevLabel = "ERROR";
+ sevClass = "bg-error-container text-on-error-container font-bold";
+ } else if (num >= 13) {
+ sevLabel = "WARN";
+ sevClass = "bg-surface-container-high text-primary-fixed-dim font-medium";
+ }
+
+ const rowId = record.uid;
+ const isExpanded = expandedRowId === rowId;
+ const isoTime = formatIsoTimestamp(record.timeUnixNano);
+ const bodyMessage = record.body?.stringValue ?? "(empty body)";
+
+ return (
+
+
setExpandedRowId(isExpanded ? null : rowId)}
+ className="grid grid-cols-12 px-4 py-2 items-start cursor-pointer group"
+ >
+
+ {isoTime}
+
+
+
+
+ {sevLabel}
+
+
+
+
+
{record.serviceName}
+ {record.traceId && (
+
+
+ link
+
+ {
+ e.stopPropagation();
+ navigateToTraceSpan(record.traceId!, record.spanId);
+ }}
+ className="text-primary-container hover:underline truncate font-mono text-[10px]"
+ >
+ {record.traceId.slice(0, 12)}
+
+
+ )}
+
+
+
+
+ {num >= 17 && (
+ Exception:
+ )}
+ {bodyMessage}
+
+
+
+ {isExpanded ? "unfold_less" : "unfold_more"}
+
+
+
+
+ {/* Structured Payload Drawer (Expanded) */}
+ {isExpanded && (
+
+
+
+ OpenTelemetry Attributes & Resource Scope
+
+ {record.traceId && (
+ {
+ navigator.clipboard.writeText(
+ `trace:${record.traceId} span:${record.spanId || ""}`,
+ );
+ alert("Copied trace context to clipboard.");
+ }}
+ className="flex items-center gap-1 font-mono text-[10px] text-primary-container hover:text-primary transition-colors"
+ >
+ copy_all
+ Copy Trace Context
+
+ )}
+
+
+ {/* Attributes Grid */}
+
+ {record.spanId && (
+
+ span_id
+
+ {record.spanId}
+
+
+ )}
+ {record.traceId && (
+
+ trace_id
+ {record.traceId}
+
+ )}
+ {record.scope && (
+
+ scope
+ {record.scope}
+
+ )}
+
+ service.name
+ {record.serviceName}
+
+
+
+ {/* Raw JSON block */}
+
+
+ {JSON.stringify(
+ {
+ timestamp: isoTime,
+ severity: sevLabel,
+ service_name: record.serviceName,
+ trace_id: record.traceId,
+ span_id: record.spanId,
+ body: bodyMessage,
+ attributes: Object.fromEntries(
+ (record.attributes ?? []).map((a) => [
+ a.key,
+ formatAttributeValue(a.value),
+ ]),
+ ),
+ resource_attributes: Object.fromEntries(
+ (record.resourceAttributes ?? []).map((a) => [
+ a.key,
+ formatAttributeValue(a.value),
+ ]),
+ ),
+ },
+ null,
+ 2,
+ )}
+
+
+
+ )}
+
+ );
+ })
+ )}
+
+
+ {/* Log Terminal Footer Status */}
+
+
+
+ Showing {visibleRecords.length} of {allRecords.length} matched events
+
+
+ Buffer: Healthy
+
+
+
+
+ Auto-scroll: LIVE STREAM
+
+
+
+
+ );
+};
diff --git a/otel-ui/src/components/Tabs.tsx b/otel-ui/src/components/Tabs.tsx
new file mode 100644
index 0000000..c65e1ce
--- /dev/null
+++ b/otel-ui/src/components/Tabs.tsx
@@ -0,0 +1,513 @@
+import { useCallback, useState } from "react"
+import * as Tabs from "@radix-ui/react-tabs"
+import {
+ Activity,
+ AlertOctagon,
+ LineChart,
+ ScrollText,
+ AlertTriangle,
+ Info,
+ CheckCircle2,
+ ChevronDown,
+ ChevronUp,
+ TrendingUp,
+ Wrench,
+ Radar,
+ Globe,
+ GitBranch,
+ CloudCog,
+} from "lucide-react"
+import type { LucideIcon } from "lucide-react"
+import { MetricsVisualizer } from "./MetricsVisualizer"
+import TraceVisualization from "./TraceVisualization"
+import { IssuesVisualizer } from "./IssuesVisualizer"
+import { SetupPanel } from "./SetupPanel"
+import { SearchAndWatcher } from "./SearchAndWatcher"
+import { FrontendVisualizer } from "./FrontendVisualizer"
+import { ViewErrorBoundary } from "./ViewErrorBoundary"
+import { useObservabilityData } from "../hooks/useObservabilityData"
+import { useIssues } from "../hooks/useIssues"
+import { useStatsAvailability } from "../hooks/useStats"
+import { TrendsVisualizer } from "./TrendsVisualizer"
+import { authFetch } from "../lib/auth"
+import type { Issue, LogEntry, OtelAttribute } from "../lib/types"
+import { formatAttributeValue, getServicePalette, getServiceName } from "../lib/otel-utils"
+import { TelemetryFilters } from "./TelemetryFilters"
+import { PatternsVisualizer } from "./PatternsVisualizer"
+import { RolloutsVisualizer } from "./RolloutsVisualizer"
+
+
+type SeverityMeta = {
+ baseLabel: string
+ gradient: string
+ ring: string
+ icon: LucideIcon
+}
+
+const severityStyles: Record = {
+ ERROR: {
+ baseLabel: "Error",
+ gradient: "from-rose-500 via-red-500 to-orange-400",
+ ring: "ring-rose-400/30",
+ icon: AlertTriangle,
+ },
+ WARN: {
+ baseLabel: "Warning",
+ gradient: "from-amber-400 via-orange-400 to-rose-400",
+ ring: "ring-amber-300/30",
+ icon: AlertTriangle,
+ },
+ INFO: {
+ baseLabel: "Info",
+ gradient: "from-sky-500 via-blue-500 to-violet-500",
+ ring: "ring-sky-400/30",
+ icon: Info,
+ },
+ DEBUG: {
+ baseLabel: "Debug",
+ gradient: "from-emerald-400 via-teal-400 to-cyan-400",
+ ring: "ring-emerald-300/30",
+ icon: CheckCircle2,
+ },
+}
+
+const fallbackSeverity: SeverityMeta = {
+ baseLabel: "Log",
+ gradient: "from-slate-600 via-slate-500 to-slate-400",
+ ring: "ring-white/20",
+ icon: Info,
+}
+
+const getSeverityPresentation = (
+ severityText?: string,
+ severityNumber?: number,
+) => {
+ if (severityText) {
+ const normalized = severityText.toUpperCase()
+ if (severityStyles[normalized]) {
+ const meta = severityStyles[normalized]
+ return { ...meta, display: meta.baseLabel }
+ }
+ return { ...fallbackSeverity, display: severityText }
+ }
+
+ if (severityNumber !== undefined) {
+ if (severityNumber >= 17) {
+ const meta = severityStyles.ERROR
+ return { ...meta, display: meta.baseLabel }
+ }
+ if (severityNumber >= 13) {
+ const meta = severityStyles.WARN
+ return { ...meta, display: meta.baseLabel }
+ }
+ if (severityNumber >= 9) {
+ const meta = severityStyles.INFO
+ return { ...meta, display: meta.baseLabel }
+ }
+ const meta = severityStyles.DEBUG
+ return { ...meta, display: meta.baseLabel }
+ }
+
+ return { ...fallbackSeverity, display: fallbackSeverity.baseLabel }
+}
+
+// tabItems moved inside component to access live data
+
+export const LogEntryView = ({ log }: { log: LogEntry }) => {
+ const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
+ const [expandedId, setExpandedId] = useState(null)
+
+ const logRecords = (log.scopeLogs ?? []).flatMap((scopeLog) =>
+ (scopeLog.logRecords ?? []).map((record) => ({
+ ...record,
+ scope: scopeLog.scope?.name,
+ resourceAttributes: log.resource?.attributes ?? [],
+ })),
+ )
+
+ const getRecordServiceName = (recordAttributes: OtelAttribute[]) => {
+ return getServiceName(log.resource?.attributes ?? [], recordAttributes)
+ }
+
+ const navigateToSpan = (traceId: string, spanId?: string) => {
+ setActiveTraceId(traceId)
+ if (spanId) setActiveSpanId(spanId)
+ setActiveTab("tab1")
+ }
+
+ const formatTimestamp = (timeUnixNano: string) => {
+ const timestamp = (parseInt(timeUnixNano ?? "0") || 0) / 1e9
+ const date = new Date(timestamp * 1000)
+ return date.toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 })
+ }
+
+ if (logRecords.length === 0) {
+ return null
+ }
+
+ return (
+
+ {logRecords.map((record) => {
+ const severity = getSeverityPresentation(
+ record.severityText,
+ record.severityNumber,
+ )
+ const bodyMessage = record.body?.stringValue ?? "(no body)"
+ const recordKey = record.uid
+ const isExpanded = expandedId === recordKey
+
+ return (
+
+ {/* The Main Log Row */}
+
setExpandedId(isExpanded ? null : recordKey)}
+ className="flex cursor-pointer flex-wrap items-center gap-x-4 gap-y-2 p-2.5 font-mono text-[12px]"
+ >
+
+
+ {/* Timestamp */}
+
+ {formatTimestamp(record.timeUnixNano)}
+
+
+ {/* Service Badge */}
+ {(() => {
+ const sName = getRecordServiceName(record.attributes ?? [])
+ const palette = getServicePalette(sName)
+ return (
+
+ {sName}
+
+ )
+ })()}
+
+ {/* Severity Badge */}
+
+ {severity.display}
+
+
+ {/* Log Message */}
+
+ {bodyMessage}
+
+
+ {/* Action Icons */}
+
+ {record.traceId && (
+
{
+ e.stopPropagation()
+ navigateToSpan(record.traceId!, record.spanId)
+ }}
+ className="group/trace flex items-center gap-1.5 rounded-full bg-sky-500/10 border border-sky-400/20 px-2.5 py-0.5 text-[10px] font-bold text-sky-400 hover:bg-sky-500/20 hover:border-sky-400/30 transition-all"
+ >
+
+ TRACE:{record.traceId.slice(0, 6)}
+
+ )}
+ {isExpanded ?
:
}
+
+
+
+ {/* Expansion Content */}
+ {isExpanded && (
+
+
+ {(record.attributes ?? []).length > 0 && (
+
+
Attributes
+
+ {(record.attributes ?? []).map(attr => (
+
+ {attr.key}:
+ {formatAttributeValue(attr.value)}
+
+ ))}
+
+
+ )}
+ {(record.resourceAttributes ?? []).length > 0 && (
+
+
Resource
+
+ {(record.resourceAttributes ?? []).map(attr => (
+
+ {attr.key}:
+ {formatAttributeValue(attr.value)}
+
+ ))}
+
+
+ )}
+
+
+
+ Scope: {record.scope || "unknown"}
+ {record.spanId && Span ID: {record.spanId} }
+ {record.traceId && Trace ID: {record.traceId} }
+
+
+ {record.traceId && (
+
navigateToSpan(record.traceId!, record.spanId)}
+ className="inline-flex items-center gap-2 rounded-xl bg-sky-500 px-4 py-2 text-[10px] font-black uppercase tracking-widest text-slate-950 transition-all hover:bg-sky-400 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-sky-500/20"
+ >
+
+ Jump to Span in Trace
+
+ )}
+
+
+ )}
+
+ )
+ })}
+
+ )
+}
+
+import { StructuredLogTerminal } from "./StructuredLogTerminal"
+
+const LogVisualizer = ({ logs }: { logs: LogEntry[] }) => {
+ return
+}
+
+export const TabsComponent = () => {
+ const { traces, logs, metrics, activeTab, setActiveTab } = useObservabilityData()
+ const issuesState = useIssues()
+ const [statusBusy, setStatusBusy] = useState(false)
+ // Trends tab only exists when the backend has ClickHouse-backed stats;
+ // null while the capability check is in flight.
+ const statsAvailable = useStatsAvailability()
+
+ // Triage action: PATCH the issue status, then refresh so counts/badges
+ // reflect the server state. Failures surface via the issues error banner.
+ const handleIssueStatusChange = useCallback(
+ async (issue: Issue, status: string) => {
+ setStatusBusy(true)
+ try {
+ const res = await authFetch(`/api/issues/${issue.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ status }),
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ } catch (err) {
+ console.error("Failed to update issue status:", err)
+ } finally {
+ await issuesState.refresh()
+ setStatusBusy(false)
+ }
+ },
+ [issuesState],
+ )
+
+ const numberFormatter = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ maximumFractionDigits: 1,
+ })
+
+ const frontendCount = logs.reduce((acc, log) => {
+ return (
+ acc +
+ (log.scopeLogs ?? []).reduce((sAcc, sl) => {
+ return (
+ sAcc +
+ (sl.logRecords ?? []).filter((r) => {
+ const attrs = r.attributes ?? []
+ return (
+ attrs.some((a) => a.key === "source" && a.value?.stringValue === "frontend") ||
+ sl.scope?.name === "otel-rum" ||
+ attrs.some((a) => a.key === "rum.vital") ||
+ attrs.some((a) => a.key === "browser.url")
+ )
+ }).length
+ )
+ }, 0)
+ )
+ }, 0)
+
+ const tabItems = [
+ {
+ value: "tab0",
+ label: "Issues",
+ description: "Deduplicated errors",
+ Icon: AlertOctagon,
+ count: issuesState.issues.length
+ },
+ {
+ value: "tab8",
+ label: "Patterns",
+ description: "Deterministic similarity review",
+ Icon: GitBranch,
+ count: undefined as number | undefined,
+ },
+ {
+ value: "tab9",
+ label: "Rollouts",
+ description: "Cluster deploy correlation",
+ Icon: CloudCog,
+ count: undefined as number | undefined,
+ },
+ {
+ value: "tab1",
+ label: "Traces",
+ description: "Span timelines & focus",
+ Icon: Activity,
+ count: traces.length
+ },
+ {
+ value: "tab2",
+ label: "Metrics",
+ description: "Performance trends",
+ Icon: LineChart,
+ count: metrics.length
+ },
+ {
+ value: "tab3",
+ label: "Logs",
+ description: "System log streams",
+ Icon: ScrollText,
+ count: logs.length
+ },
+ {
+ value: "tab7",
+ label: "Frontend & RUM",
+ description: "Web Vitals & browser errors",
+ Icon: Globe,
+ count: frontendCount,
+ },
+ // Historical trends need the ClickHouse-backed stats endpoints.
+ ...(statsAvailable
+ ? [
+ {
+ value: "tab5",
+ label: "Trends",
+ description: "History graphs & release comparison",
+ Icon: TrendingUp,
+ count: undefined as number | undefined,
+ },
+ ]
+ : []),
+ {
+ value: "tab6",
+ label: "Archive & Trap",
+ description: "Persistent storage search & signal trap",
+ Icon: Radar,
+ count: undefined,
+ },
+ {
+ value: "tab4",
+ label: "Setup",
+ description: "Instrument a new service",
+ Icon: Wrench,
+ count: undefined
+ },
+ ]
+
+ return (
+
+
+
+
+ {tabItems.map(({ value, label, Icon, count }) => (
+
+
+
+ {label}
+ {count !== undefined && (
+
+ {numberFormatter.format(count)}
+
+ )}
+
+ ))}
+
+
+ {activeTab === "tab0" && (
+
+
+
+ )}
+
+
+ {activeTab === "tab1" && }
+
+
+ {activeTab === "tab8" && }
+
+
+ {activeTab === "tab9" && }
+
+
+ {activeTab === "tab2" && }
+
+
+ {activeTab === "tab3" && }
+
+
+ {activeTab === "tab7" && }
+
+
+ {activeTab === "tab4" && }
+
+ {statsAvailable && (
+
+ {activeTab === "tab5" && }
+
+ )}
+
+ {activeTab === "tab6" && }
+
+
+
+ )
+
+}
diff --git a/otel-ui/src/components/TelemetryFilters.tsx b/otel-ui/src/components/TelemetryFilters.tsx
new file mode 100644
index 0000000..1342d47
--- /dev/null
+++ b/otel-ui/src/components/TelemetryFilters.tsx
@@ -0,0 +1,124 @@
+import { X, Activity, AlertTriangle } from "lucide-react"
+import { useObservabilityData } from "../hooks/useObservabilityData"
+import { getServicePalette } from "../lib/otel-utils"
+
+export const TelemetryFilters = () => {
+ const {
+ selectedServices,
+ toggleService,
+ excludedAttributes,
+ removeExcludedValue,
+ clearAttributeKey,
+ uniqueServices,
+ filtersExpanded: isExpanded,
+ } = useObservabilityData()
+
+ const hasExcludedKeys = Object.keys(excludedAttributes).length > 0
+
+ if (!isExpanded) return null
+
+ return (
+
+
+ {/* Service Multi-Select */}
+
+
+
+ {uniqueServices.map((service) => {
+ const isActive = selectedServices.has(service)
+ const palette = getServicePalette(service)
+ return (
+ toggleService(service)}
+ className={`group relative inline-flex items-center gap-3 rounded-2xl border px-5 py-2.5 text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300 ${isActive
+ ? `border-transparent bg-gradient-to-br ${palette.gradient} text-slate-950 shadow-lg shadow-sky-500/20`
+ : "border-white/10 bg-slate-900/40 text-slate-400 hover:border-white/20 hover:text-white"
+ }`}
+ >
+ {!isActive && (
+
+ )}
+ {service}
+ {isActive && (
+
+
+
+ )}
+
+ )
+ })}
+ {uniqueServices.length === 0 && (
+
+ Listening for service signals...
+
+ )}
+
+
+
+ {/* Attribute Exclusions */}
+
+
+
+
+ Active Noise Suppression
+
+
+
+ {hasExcludedKeys ? (
+ Object.entries(excludedAttributes).map(([key, valueSet]) => (
+
+
+
+ {key}
+
+ clearAttributeKey(key)}
+ className="text-[9px] font-black uppercase tracking-widest text-slate-600 hover:text-white transition-colors ml-4"
+ >
+ Remove All
+
+
+
+ {Array.from(valueSet).map((value) => (
+ removeExcludedValue(key, value)}
+ className="group inline-flex items-center gap-2 rounded-full border border-white/5 bg-slate-950/80 px-2.5 py-0.5 text-[10px] text-slate-300 transition-all hover:border-rose-500/50 hover:text-rose-400"
+ >
+ {value}
+
+
+ ))}
+
+
+ ))
+ ) : (
+
+
+ No active attribute exclusions.
+
+
+ Click attributes in details to suppress chatty items
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/otel-ui/src/components/TopHeader.tsx b/otel-ui/src/components/TopHeader.tsx
new file mode 100644
index 0000000..b5207da
--- /dev/null
+++ b/otel-ui/src/components/TopHeader.tsx
@@ -0,0 +1,232 @@
+import React, { useRef, useEffect } from "react";
+import { useObservabilityData } from "../hooks/useObservabilityData";
+import { useIssues } from "../hooks/useIssues";
+import { currentUser, login, logout } from "../lib/auth";
+
+export const TopHeader: React.FC = () => {
+ const user = currentUser();
+ const {
+ searchQuery,
+ setSearchQuery,
+ showErrorsOnly,
+ setShowErrorsOnly,
+ resetFilters,
+ setActiveTab,
+ timeWindow,
+ setTimeWindow,
+ isStreaming,
+ setIsStreaming,
+ bufferedCount,
+ flushBufferedStream,
+ minDurationMs,
+ setMinDurationMs,
+ } = useObservabilityData();
+
+ const { issues } = useIssues();
+ const searchInputRef = useRef(null);
+
+ const openIssuesCount = React.useMemo(() => {
+ return issues.filter((i) => !i.status || i.status === "open").length;
+ }, [issues]);
+
+ // ⌘K / Ctrl+K keyboard shortcut to focus search
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
+ e.preventDefault();
+ searchInputRef.current?.focus();
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, []);
+
+ return (
+
+ {/* Search and Time Range */}
+
+ {/* Global Search Bar */}
+
+
+ search
+
+
setSearchQuery(e.target.value)}
+ placeholder="⌘K search attributes, services, traceId, spanId, or log payload..."
+ className="w-full h-8 pl-8 pr-16 rounded-lg bg-surface-container-low text-on-surface font-mono text-xs placeholder:text-outline focus:outline-none focus:bg-surface-container transition-colors border border-surface-container/40"
+ />
+
+
+ ⌘
+
+
+ K
+
+
+
+
+ {/* Real Time Window Selector */}
+
+
+ schedule
+
+ {(["15m", "1h", "6h", "24h", "all"] as const).map((w) => (
+ setTimeWindow(w)}
+ className={`px-2 py-0.5 font-mono text-[11px] rounded transition-colors ${
+ timeWindow === w
+ ? "bg-surface-container text-primary-container font-bold shadow-sm"
+ : "text-outline hover:text-on-surface"
+ }`}
+ >
+ {w === "all" ? "All" : w}
+
+ ))}
+
+
+
+ {/* Action Buttons & Streaming Controls */}
+
+ {/* Real Streaming Pause/Resume Button */}
+
{
+ if (isStreaming) {
+ setIsStreaming(false);
+ } else {
+ setIsStreaming(true);
+ flushBufferedStream();
+ }
+ }}
+ className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-mono text-[11px] font-semibold transition-colors border ${
+ isStreaming
+ ? "bg-surface-container-low text-secondary border-surface-container hover:bg-surface-container"
+ : "bg-amber-500/20 text-amber-300 border-amber-500/40 animate-pulse"
+ }`}
+ title={isStreaming ? "Pause stream to freeze buffer for inspection" : "Resume live telemetry tail"}
+ >
+
+
+ {isStreaming
+ ? "Live Tail"
+ : `Paused ${bufferedCount > 0 ? `(${bufferedCount})` : ""}`}
+
+
+
+ {/* All vs Errors Toggle */}
+
+ setShowErrorsOnly(false)}
+ className={`px-2.5 py-1 text-[10px] font-mono font-bold uppercase tracking-wider rounded transition-colors ${
+ !showErrorsOnly
+ ? "bg-surface-container-high text-primary font-bold shadow-sm"
+ : "text-outline hover:text-on-surface"
+ }`}
+ >
+ All
+
+ setShowErrorsOnly(true)}
+ className={`flex items-center gap-1 px-2.5 py-1 text-[10px] font-mono font-bold uppercase tracking-wider rounded transition-colors ${
+ showErrorsOnly
+ ? "bg-error-container text-on-error-container font-bold shadow-sm"
+ : "text-outline hover:text-error"
+ }`}
+ >
+ warning
+ Errors
+
+
+
+ {/* Slow Spans Toggle (>500ms) */}
+
setMinDurationMs(minDurationMs > 0 ? 0 : 500)}
+ className={`flex items-center gap-1 px-2 py-1 text-[10px] font-mono font-bold uppercase tracking-wider rounded-lg border transition-colors ${
+ minDurationMs > 0
+ ? "bg-amber-500/20 text-amber-300 border-amber-500/40 font-bold"
+ : "bg-surface-container-low text-outline hover:text-on-surface border-surface-container/40"
+ }`}
+ title="Filter to slow spans (>500ms latency)"
+ >
+ speed
+ >500ms
+
+
+
{
+ resetFilters();
+ setMinDurationMs(0);
+ }}
+ className="px-2 py-1 text-[10px] font-mono font-bold uppercase tracking-wider text-outline hover:text-on-surface rounded-lg bg-surface-container-low hover:bg-surface-container border border-surface-container/40 transition-colors"
+ title="Reset active filters"
+ >
+ Reset
+
+
+
+
+ {/* Active Issues / Incident Counter Button */}
+
setActiveTab("tab0")}
+ aria-label="Active issues"
+ className={`relative flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-mono text-xs font-bold transition-colors border ${
+ openIssuesCount > 0
+ ? "bg-error-container text-on-error-container border-error/30 animate-pulse"
+ : "bg-surface-container-low text-outline hover:text-on-surface border-surface-container/40"
+ }`}
+ title="Open Issues & Anomalies"
+ >
+ notifications_active
+ {openIssuesCount}
+
+
+ {/* User Account / Auth */}
+ {user ? (
+
+
+ {(user.name || user.email || user.sub).charAt(0).toUpperCase()}
+
+
+
+ {user.name || user.email?.split("@")[0] || user.sub}
+
+ logout()}
+ className="font-mono text-[10px] text-outline hover:text-error text-left leading-none transition-colors"
+ >
+ Sign out
+
+
+
+ ) : (
+
login()}
+ className="flex items-center gap-1 px-2.5 py-1 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 font-mono text-xs transition-colors border border-primary/20 select-none"
+ >
+ login
+ Sign in
+
+ )}
+
+
+ );
+};
diff --git a/otel-ui/src/components/TraceVisualization.tsx b/otel-ui/src/components/TraceVisualization.tsx
new file mode 100644
index 0000000..d6d0536
--- /dev/null
+++ b/otel-ui/src/components/TraceVisualization.tsx
@@ -0,0 +1,1866 @@
+import { useMemo, useState, useRef, useEffect } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import {
+ Sparkles,
+ ChevronDown,
+ ChevronRight,
+ ChevronLeft,
+ ScrollText,
+ Activity,
+ AlertTriangle,
+ Layers,
+ Search,
+ X,
+ Copy,
+ Check,
+ SlidersHorizontal,
+ TableProperties,
+ ArrowUpDown,
+ ListFilter,
+} from "lucide-react";
+import type { TraceData, Span } from "../lib/types";
+import { formatDuration, formatRelativeTime } from "../lib/timeUtils";
+import { formatAttributeValue, getServicePalette } from "../lib/otel-utils";
+import { useLogsForTraceId } from "../hooks/useLogsForTraceId";
+import { useObservabilityData } from "../hooks/useObservabilityData";
+import { LogEntryView } from "./Tabs";
+import { ServiceGraph, type GraphSpan } from "./ServiceGraph";
+
+export type ProcessedSpan = Span & {
+ service: string;
+ startTime: number;
+ endTime: number;
+ duration: number;
+ formattedAttributes: Record;
+ children?: ProcessedSpan[];
+ logCount: number;
+ hasError: boolean;
+};
+
+export type ProcessedTrace = {
+ id: string;
+ name: string;
+ spans: ProcessedSpan[];
+ startTime: number;
+ endTime: number;
+ duration: number;
+ services: string[];
+ hasError: boolean;
+ errorCount: number;
+};
+
+// Flattened item for virtualized tree rendering
+interface FlattenedSpanItem {
+ span: ProcessedSpan;
+ depth: number;
+ hasChildren: boolean;
+ isCollapsed: boolean;
+ offsetPercent: number;
+ widthPercent: number;
+}
+
+const buildTraceTree = (spans: ProcessedSpan[]): ProcessedSpan[] => {
+ const spanMap: Record = {};
+ spans.forEach((span) => {
+ spanMap[span.spanId] = { ...span, children: [] };
+ });
+
+ const rootSpans: ProcessedSpan[] = [];
+ spans.forEach((span) => {
+ const processed = spanMap[span.spanId];
+ if (span.parentSpanId && spanMap[span.parentSpanId]) {
+ spanMap[span.parentSpanId].children!.push(processed);
+ } else {
+ rootSpans.push(processed);
+ }
+ });
+
+ const sortChildren = (node: ProcessedSpan) => {
+ node.children?.sort((a, b) => a.startTime - b.startTime);
+ node.children?.forEach(sortChildren);
+ };
+ rootSpans.forEach(sortChildren);
+ rootSpans.sort((a, b) => a.startTime - b.startTime);
+
+ return rootSpans;
+};
+
+// Flatten the tree into an array of visible items based on collapsed state
+const flattenSpanTree = (
+ nodes: ProcessedSpan[],
+ collapsedSet: Set,
+ traceMinTime: number,
+ traceTotalRange: number,
+ depth = 0,
+): FlattenedSpanItem[] => {
+ const result: FlattenedSpanItem[] = [];
+
+ for (const span of nodes) {
+ const hasChildren = Boolean(span.children && span.children.length > 0);
+ const isCollapsed = collapsedSet.has(span.spanId);
+
+ const offsetPercent =
+ traceTotalRange === 0 ? 0 : ((span.startTime - traceMinTime) / traceTotalRange) * 100;
+ const widthPercent =
+ traceTotalRange === 0 ? 100 : (span.duration / traceTotalRange) * 100;
+
+ result.push({
+ span,
+ depth,
+ hasChildren,
+ isCollapsed,
+ offsetPercent: Math.max(0, Math.min(offsetPercent, 100)),
+ widthPercent: Math.max(0.5, Math.min(widthPercent, 100 - offsetPercent)),
+ });
+
+ if (hasChildren && !isCollapsed && span.children) {
+ result.push(
+ ...flattenSpanTree(
+ span.children,
+ collapsedSet,
+ traceMinTime,
+ traceTotalRange,
+ depth + 1,
+ ),
+ );
+ }
+ }
+
+ return result;
+};
+
+const getOperationDisplayName = (span: ProcessedSpan) => {
+ let name = span.name;
+ if (!name || name === "/" || name === "GET /") {
+ const method = span.formattedAttributes["http.method"];
+ const target = span.formattedAttributes["http.target"];
+ const route = span.formattedAttributes["http.route"];
+ const urlPath = span.formattedAttributes["url.path"];
+ const rpcMethod = span.formattedAttributes["rpc.method"];
+
+ if (route) name = route;
+ else if (method && target) name = `${method} ${target}`;
+ else if (rpcMethod) name = rpcMethod;
+ else if (urlPath) name = urlPath;
+ else if (method) name = method;
+ }
+ return name || "span";
+};
+
+// Span Detail Inspector Drawer
+const SpanInspector = ({
+ span,
+ onClose,
+ traceTotalRange,
+}: {
+ span: ProcessedSpan;
+ onClose: () => void;
+ traceTotalRange: number;
+}) => {
+ const [tab, setTab] = useState<"overview" | "attributes" | "events" | "logs">("overview");
+ const [copiedKey, setCopiedKey] = useState(null);
+ const [attrSearch, setAttrSearch] = useState("");
+ const { toggleExcludedValue, setSearchQuery, setActiveTab } = useObservabilityData();
+
+ const copyToClipboard = (text: string, key: string) => {
+ navigator.clipboard.writeText(text);
+ setCopiedKey(key);
+ setTimeout(() => setCopiedKey(null), 1500);
+ };
+
+ const logs = useLogsForTraceId(span.traceId);
+ const spanLogs = useMemo(() => {
+ return (logs ?? []).filter((log) =>
+ (log.scopeLogs ?? []).some((sl) =>
+ (sl.logRecords ?? []).some((lr) => lr.spanId === span.spanId),
+ ),
+ );
+ }, [logs, span.spanId]);
+
+ const palette = getServicePalette(span.service);
+ const attributeEntries = Object.entries(span.formattedAttributes);
+ const filteredAttributes = useMemo(() => {
+ if (!attrSearch.trim()) return attributeEntries;
+ const query = attrSearch.toLowerCase().trim();
+ return attributeEntries.filter(
+ ([k, v]) => k.toLowerCase().includes(query) || v.toLowerCase().includes(query),
+ );
+ }, [attributeEntries, attrSearch]);
+
+ const percentageOfTrace =
+ traceTotalRange > 0 ? ((span.duration / traceTotalRange) * 100).toFixed(1) : "100";
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ {span.service}
+
+
+ {span.hasError ? "Error" : "OK"}
+
+
+ {formatDuration(span.duration)} ({percentageOfTrace}%)
+
+
+
+ {getOperationDisplayName(span)}
+
+
+
+
+
+
+
+
+ {/* Navigation Tabs */}
+
+ setTab("overview")}
+ className={`pb-2 px-2 font-bold transition-all border-b-2 ${
+ tab === "overview"
+ ? "border-sky-400 text-sky-400"
+ : "border-transparent text-slate-400 hover:text-slate-200"
+ }`}
+ >
+ Overview
+
+ setTab("attributes")}
+ className={`pb-2 px-2 font-bold transition-all border-b-2 ${
+ tab === "attributes"
+ ? "border-sky-400 text-sky-400"
+ : "border-transparent text-slate-400 hover:text-slate-200"
+ }`}
+ >
+ Attributes ({attributeEntries.length})
+
+ setTab("events")}
+ className={`pb-2 px-2 font-bold transition-all border-b-2 ${
+ tab === "events"
+ ? "border-sky-400 text-sky-400"
+ : "border-transparent text-slate-400 hover:text-slate-200"
+ }`}
+ >
+ Events ({(span.events ?? []).length})
+
+ setTab("logs")}
+ className={`pb-2 px-2 font-bold transition-all border-b-2 ${
+ tab === "logs"
+ ? "border-sky-400 text-sky-400"
+ : "border-transparent text-slate-400 hover:text-slate-200"
+ }`}
+ >
+ Logs ({spanLogs.length})
+
+
+
+ {/* Content Area */}
+
+ {tab === "overview" && (
+
+
+
+
+ Duration
+
+
+ {formatDuration(span.duration)}
+
+
{percentageOfTrace}% of total trace
+
+
+
+ Start Time
+
+
+ {new Date(span.startTime / 1_000_000).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ fractionalSecondDigits: 3,
+ })}
+
+
+ {formatRelativeTime(span.startTime / 1_000_000)}
+
+
+
+
+
+
+ Identifiers
+
+
+
+
Span ID:
+
+ {span.spanId}
+ copyToClipboard(span.spanId, "spanId")}
+ className="p-1 text-slate-500 hover:text-white"
+ title="Copy Span ID"
+ >
+ {copiedKey === "spanId" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {span.parentSpanId && (
+
+
Parent ID:
+
+ {span.parentSpanId}
+ copyToClipboard(span.parentSpanId!, "parentId")}
+ className="p-1 text-slate-500 hover:text-white"
+ title="Copy Parent ID"
+ >
+ {copiedKey === "parentId" ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+
+
+
Trace ID:
+
+
+ {span.traceId}
+
+ copyToClipboard(span.traceId, "traceId")}
+ className="p-1 text-slate-500 hover:text-white"
+ title="Copy Trace ID"
+ >
+ {copiedKey === "traceId" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {
+ setSearchQuery(span.spanId);
+ setActiveTab("tab3");
+ }}
+ className="w-full flex items-center justify-center gap-2 rounded-xl bg-sky-500/10 border border-sky-500/20 py-2.5 text-xs font-bold text-sky-400 hover:bg-sky-500/20 transition-all font-mono"
+ >
+
+ Find Logs Matching This Span
+
+
+
+ )}
+
+ {tab === "attributes" && (
+
+
+
+ setAttrSearch(e.target.value)}
+ placeholder="Search attributes..."
+ className="w-full rounded-xl border border-white/10 bg-black/40 py-1.5 pl-8 pr-3 text-xs text-white placeholder-slate-500 focus:border-sky-500/40 focus:outline-none"
+ />
+ {attrSearch && (
+ setAttrSearch("")}
+ className="absolute right-2 text-slate-500 hover:text-white"
+ >
+
+
+ )}
+
+
+ {filteredAttributes.length === 0 ? (
+
No matching attributes found.
+ ) : (
+
+ {filteredAttributes.map(([key, value]) => (
+
+
+
{key}
+
+ toggleExcludedValue(key, value)}
+ className="text-[9px] uppercase tracking-wider text-slate-500 hover:text-amber-400"
+ title="Filter out this value"
+ >
+ Exclude
+
+ copyToClipboard(value, key)}
+ className="p-1 text-slate-500 hover:text-white"
+ title="Copy value"
+ >
+ {copiedKey === key ? (
+
+ ) : (
+
+ )}
+
+
+
+
{value}
+
+ ))}
+
+ )}
+
+ )}
+
+ {tab === "events" && (
+
+ {(!span.events || span.events.length === 0) ? (
+
+ No events or exceptions attached to this span.
+
+ ) : (
+ span.events.map((event, idx) => (
+
+
+ {event.name}
+
+ {new Date(Number(event.timeUnixNano ?? "0") / 1_000_000).toLocaleTimeString()}
+
+
+ {event.attributes && event.attributes.length > 0 && (
+
+ {event.attributes.map((attr, aIdx) => (
+
+ {attr.key}:
+
+ {formatAttributeValue(attr.value)}
+
+
+ ))}
+
+ )}
+
+ ))
+ )}
+
+ )}
+
+ {tab === "logs" && (
+
+ {spanLogs.length === 0 ? (
+
+ No log entries were emitted within this span.
+
+ ) : (
+ spanLogs.map((log) =>
)
+ )}
+
+ )}
+
+
+ );
+};
+
+// Virtualized Span Waterfall View
+interface VirtualizedSpanWaterfallProps {
+ trace: ProcessedTrace;
+ selectedSpanId: string | null;
+ onSelectSpan: (spanId: string) => void;
+ filterService: string | null;
+}
+
+const VirtualizedSpanWaterfall = ({
+ trace,
+ selectedSpanId,
+ onSelectSpan,
+ filterService,
+}: VirtualizedSpanWaterfallProps) => {
+ const [collapsedSpans, setCollapsedSpans] = useState>(new Set());
+ const [searchQuery, setSearchQuery] = useState("");
+ const [errorsOnly, setErrorsOnly] = useState(false);
+ const [minDurationMsFilter, setMinDurationMsFilter] = useState(0);
+ const parentRef = useRef(null);
+
+ // Build tree
+ const rootSpans = useMemo(() => buildTraceTree(trace.spans), [trace.spans]);
+
+ // Flatten tree based on collapse state
+ const flattenedItems = useMemo(() => {
+ return flattenSpanTree(
+ rootSpans,
+ collapsedSpans,
+ trace.startTime,
+ trace.duration,
+ );
+ }, [rootSpans, collapsedSpans, trace.startTime, trace.duration]);
+
+ // Apply search & service filter
+ const visibleItems = useMemo(() => {
+ return flattenedItems.filter((item) => {
+ if (filterService && item.span.service !== filterService) return false;
+ if (errorsOnly && !item.span.hasError) return false;
+ if (minDurationMsFilter > 0) {
+ const durMs = item.span.duration / 1_000_000;
+ if (durMs < minDurationMsFilter) return false;
+ }
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase().trim();
+ const matchesName = item.span.name.toLowerCase().includes(query);
+ const matchesService = item.span.service.toLowerCase().includes(query);
+ const matchesAttrs = Object.values(item.span.formattedAttributes).some((v) =>
+ v.toLowerCase().includes(query),
+ );
+ if (!matchesName && !matchesService && !matchesAttrs) return false;
+ }
+ return true;
+ });
+ }, [flattenedItems, filterService, errorsOnly, minDurationMsFilter, searchQuery]);
+
+
+ // Virtualizer
+ const rowVirtualizer = useVirtualizer({
+ count: visibleItems.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => 38,
+ overscan: 10,
+ });
+
+ const toggleCollapse = (spanId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setCollapsedSpans((prev) => {
+ const next = new Set(prev);
+ if (next.has(spanId)) next.delete(spanId);
+ else next.add(spanId);
+ return next;
+ });
+ };
+
+ const expandAll = () => setCollapsedSpans(new Set());
+ const collapseAll = () => {
+ const allParentIds = new Set();
+ trace.spans.forEach((s) => {
+ if (s.children && s.children.length > 0) allParentIds.add(s.spanId);
+ });
+ setCollapsedSpans(allParentIds);
+ };
+
+ // Time ruler intervals: 0%, 25%, 50%, 75%, 100%
+ const rulerTicks = [0, 0.25, 0.5, 0.75, 1];
+
+ return (
+
+ {/* Waterfall Toolbar */}
+
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Filter spans..."
+ className="h-8 w-44 sm:w-56 rounded-xl border border-white/10 bg-black/40 pl-8 pr-7 text-xs text-white placeholder-slate-500 focus:border-sky-500/50 focus:outline-none"
+ />
+ {searchQuery && (
+ setSearchQuery("")}
+ className="absolute right-2 text-slate-500 hover:text-white"
+ >
+
+
+ )}
+
+
+
setErrorsOnly((prev) => !prev)}
+ className={`flex items-center gap-1.5 rounded-xl px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider transition-all border ${
+ errorsOnly
+ ? "bg-rose-500/20 text-rose-300 border-rose-500/40 shadow-sm"
+ : "bg-white/5 text-slate-400 border-white/5 hover:text-white"
+ }`}
+ >
+
+ Errors Only
+
+
+
setMinDurationMsFilter((prev) => (prev > 0 ? 0 : 100))}
+ className={`flex items-center gap-1 rounded-xl px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider transition-all border ${
+ minDurationMsFilter > 0
+ ? "bg-amber-500/20 text-amber-300 border-amber-500/40 shadow-sm"
+ : "bg-white/5 text-slate-400 border-white/5 hover:text-white"
+ }`}
+ title="Filter to spans with duration >= 100ms"
+ >
+ >100ms
+
+
+
+
+
+
+
+ Expand All
+
+
+ Collapse All
+
+
+
+
+ {visibleItems.length} of {trace.spans.length} Spans
+
+
+
+
+ {/* Synchronized Time Ruler Header */}
+
+
+ Hierarchy & Operation
+ Duration
+
+
+
+ {rulerTicks.map((pct) => (
+
+ {formatDuration(trace.duration * pct)}
+
+ ))}
+
+
+
+ {/* Virtualized Span Rows Container */}
+
+ {visibleItems.length === 0 ? (
+
+ No spans match the current filters.
+
+ ) : (
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => {
+ const item = visibleItems[virtualRow.index];
+ const isSelected = selectedSpanId === item.span.spanId;
+ const palette = getServicePalette(item.span.service);
+
+ return (
+
onSelectSpan(item.span.spanId)}
+ className={`absolute left-0 top-0 w-full grid grid-cols-12 items-center px-4 cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-sky-500/15 border-l-4 border-sky-400 shadow-inner"
+ : "hover:bg-white/[0.03]"
+ }`}
+ style={{
+ height: `${virtualRow.size}px`,
+ transform: `translateY(${virtualRow.start}px)`,
+ }}
+ >
+ {/* Left Column: Tree Indent, Chevrons, Service Pill, Name, Duration */}
+
+
+ {/* Indentation with tree guide */}
+
+ {item.depth > 0 && (
+
+ )}
+
+
+ {/* Expand / Collapse Button */}
+ {item.hasChildren ? (
+
toggleCollapse(item.span.spanId, e)}
+ className="p-0.5 text-slate-500 hover:text-white transition-colors shrink-0"
+ >
+ {item.isCollapsed ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ )}
+
+ {/* Service Tag */}
+
+ {item.span.service}
+
+
+ {/* Operation Name */}
+
+ {getOperationDisplayName(item.span)}
+
+
+ {/* Error Dot Indicator */}
+ {item.span.hasError && (
+
+ )}
+
+ {/* Correlated Logs Count */}
+ {item.span.logCount > 0 && (
+
+ {item.span.logCount} logs
+
+ )}
+
+
+
+ {formatDuration(item.span.duration)}
+
+
+
+ {/* Right Column: Waterfall Bar & Time Grid */}
+
+ {/* Synchronized Vertical Guide Lines */}
+ {rulerTicks.map((pct) => (
+
+ ))}
+
+ {/* Waterfall Span Bar */}
+
+ {item.widthPercent > 12 && (
+
+ {formatDuration(item.span.duration)}
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+};
+
+// Main Trace Box with Header, Visual Time Distribution Bar, View Toggle, and Detail Drawer
+const SingleTraceView = ({
+ trace,
+}: {
+ trace: ProcessedTrace;
+}) => {
+ const [selectedSpanId, setSelectedSpanId] = useState(null);
+ const [viewMode, setViewMode] = useState<"waterfall" | "graph">("waterfall");
+ const [filterService, setFilterService] = useState(null);
+
+ const selectedSpan = useMemo(() => {
+ if (!selectedSpanId) return null;
+ return trace.spans.find((s) => s.spanId === selectedSpanId) || null;
+ }, [trace.spans, selectedSpanId]);
+
+ // Service distribution breakdown
+ const serviceBreakdown = useMemo(() => {
+ const serviceTotals = new Map();
+ trace.spans.forEach((s) => {
+ serviceTotals.set(s.service, (serviceTotals.get(s.service) || 0) + s.duration);
+ });
+
+ const sumDurations = Array.from(serviceTotals.values()).reduce((a, b) => a + b, 0) || 1;
+ return Array.from(serviceTotals.entries()).map(([service, dur]) => ({
+ service,
+ duration: dur,
+ percentage: Math.round((dur / sumDurations) * 100),
+ palette: getServicePalette(service),
+ }));
+ }, [trace.spans]);
+
+ // Transform spans for the Service Graph
+ const graphSpans: GraphSpan[] = useMemo(() => {
+ return trace.spans.map((s) => ({
+ spanId: s.spanId,
+ parentSpanId: s.parentSpanId,
+ service: s.service,
+ duration: s.duration,
+ hasError: s.hasError,
+ }));
+ }, [trace.spans]);
+
+ const startDate = new Date(trace.startTime / 1_000_000);
+
+ return (
+
+ {/* Trace Card Header */}
+
+
+ {/* Visual Service Distribution Timeline Bar */}
+
+
+
Service Time Distribution
+
+ {serviceBreakdown.map((item) => (
+
+
+ {item.service} ({item.percentage}%)
+
+ ))}
+
+
+
+ {/* Multi-segment Progress Bar */}
+
+ {serviceBreakdown.map((item) => (
+
+ ))}
+
+
+
+ {/* Main View: either Service Call Graph or Virtualized Waterfall */}
+ {viewMode === "graph" ? (
+ {
+ setFilterService(srv);
+ if (srv) setViewMode("waterfall");
+ }}
+ />
+ ) : (
+
+ {/* Virtualized Waterfall Table */}
+
+ setSelectedSpanId((prev) => (prev === id ? null : id))}
+ filterService={filterService}
+ />
+
+
+ {/* Span Detail Inspector Panel */}
+ {selectedSpan && (
+
+ setSelectedSpanId(null)}
+ traceTotalRange={trace.duration}
+ />
+
+ )}
+
+ )}
+
+ );
+};
+
+// Searchable Trace Picker Dropdown (used in Waterfall view)
+const TracePickerDropdown = ({
+ traces,
+ activeTrace,
+ onSelect,
+}: {
+ traces: ProcessedTrace[];
+ activeTrace: ProcessedTrace;
+ onSelect: (id: string) => void;
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ const handleOutside = (e: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+ if (isOpen) {
+ document.addEventListener("mousedown", handleOutside);
+ return () => document.removeEventListener("mousedown", handleOutside);
+ }
+ }, [isOpen]);
+
+ useEffect(() => {
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setIsOpen(false);
+ };
+ if (isOpen) {
+ document.addEventListener("keydown", handleKey);
+ return () => document.removeEventListener("keydown", handleKey);
+ }
+ }, [isOpen]);
+
+ const filtered = useMemo(() => {
+ if (!search.trim()) return traces;
+ const q = search.toLowerCase().trim();
+ return traces.filter(
+ (t) =>
+ t.name.toLowerCase().includes(q) ||
+ t.id.toLowerCase().includes(q) ||
+ t.services.some((s) => s.toLowerCase().includes(q)),
+ );
+ }, [traces, search]);
+
+ const rootService = activeTrace.spans[0]?.service || activeTrace.services[0] || "unknown";
+ const palette = getServicePalette(rootService);
+
+ return (
+
+
setIsOpen((prev) => !prev)}
+ className="flex items-center gap-2 rounded-xl bg-slate-900/80 border border-white/15 px-3 py-1.5 text-xs font-bold text-white hover:border-white/30 hover:bg-slate-900 transition-all shadow-sm"
+ title="Switch trace"
+ >
+
+ {rootService}
+
+ {activeTrace.name}
+
+ {formatDuration(activeTrace.duration)}
+
+ {activeTrace.hasError && (
+
+ )}
+
+
+
+ {isOpen && (
+
+
+
+ setSearch(e.target.value)}
+ placeholder={`Search ${traces.length} traces...`}
+ className="w-full h-8 rounded-xl border border-white/10 bg-black/40 pl-8 pr-7 text-xs text-white placeholder-slate-500 focus:border-sky-500/50 focus:outline-none"
+ />
+ {search && (
+ setSearch("")}
+ className="absolute right-2 text-slate-500 hover:text-white"
+ >
+
+
+ )}
+
+
+
+ {filtered.length === 0 ? (
+
+ No traces match “{search}”
+
+ ) : (
+ filtered.map((t) => {
+ const isSelected = t.id === activeTrace.id;
+ const srv = t.spans[0]?.service || t.services[0] || "unknown";
+ const pal = getServicePalette(srv);
+
+ return (
+
{
+ onSelect(t.id);
+ setIsOpen(false);
+ }}
+ className={`w-full text-left flex items-center justify-between gap-2 p-2 rounded-xl transition-all ${
+ isSelected
+ ? "bg-sky-500/20 border border-sky-400/40 text-white"
+ : "hover:bg-white/5 text-slate-300"
+ }`}
+ >
+
+ {t.hasError ? (
+
+ ) : (
+
+ )}
+
+ {srv}
+
+
+ {t.name}
+
+
+
+
+ {formatDuration(t.duration)}
+ •
+ {formatRelativeTime(t.startTime / 1_000_000)}
+
+
+ );
+ })
+ )}
+
+
+ )}
+
+ );
+};
+
+// Trace Directory Table View with Search, Filters, Comparative Duration & Sorting
+const TraceDirectoryTable = ({
+ traces,
+ activeTraceId,
+ onSelectTrace,
+ searchQuery,
+ onSearchChange,
+ filter,
+ onFilterChange,
+ serviceFilter,
+ onServiceFilterChange,
+ sortBy,
+ onSortByChange,
+ allServices,
+ onSwitchToWaterfall,
+}: {
+ traces: ProcessedTrace[];
+ activeTraceId: string | null;
+ onSelectTrace: (id: string) => void;
+ searchQuery: string;
+ onSearchChange: (q: string) => void;
+ filter: "all" | "errors" | "slow";
+ onFilterChange: (f: "all" | "errors" | "slow") => void;
+ serviceFilter: string;
+ onServiceFilterChange: (s: string) => void;
+ sortBy: "recent" | "duration-desc" | "duration-asc" | "errors" | "spans";
+ onSortByChange: (s: any) => void;
+ allServices: string[];
+ onSwitchToWaterfall: () => void;
+}) => {
+ const [copiedId, setCopiedId] = useState(null);
+
+ const copyTraceId = (id: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ navigator.clipboard.writeText(id);
+ setCopiedId(id);
+ setTimeout(() => setCopiedId(null), 1500);
+ };
+
+ const maxDuration = useMemo(() => {
+ return Math.max(...traces.map((t) => t.duration), 1);
+ }, [traces]);
+
+ const errorCount = useMemo(() => {
+ return traces.filter((t) => t.hasError).length;
+ }, [traces]);
+
+ return (
+
+ {/* Directory Header & Toolbar */}
+
+
+
+
+
+
Traces Explorer
+
+ {traces.length} traces
+
+ {errorCount > 0 && (
+
+ {errorCount} with errors
+
+ )}
+
+
+ Browse, search, and inspect distributed trace execution flows
+
+
+
+
+
+
+ Open Waterfall Detail
+
+
+
+ {/* Filter and Search Controls */}
+
+
+
+
+ onSearchChange(e.target.value)}
+ placeholder="Search by name, service, route, or traceId..."
+ className="h-8 w-full rounded-xl border border-white/10 bg-black/40 pl-8 pr-7 text-xs text-white placeholder-slate-500 focus:border-sky-500/50 focus:outline-none"
+ />
+ {searchQuery && (
+ onSearchChange("")}
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white"
+ >
+
+
+ )}
+
+
+
+
+ {/* Quick Filters */}
+
+ onFilterChange("all")}
+ className={`rounded-lg px-2.5 py-1 text-[11px] font-bold transition-all ${
+ filter === "all"
+ ? "bg-white/10 text-white shadow-sm"
+ : "text-slate-400 hover:text-white"
+ }`}
+ >
+ All
+
+ onFilterChange("errors")}
+ className={`rounded-lg px-2.5 py-1 text-[11px] font-bold transition-all ${
+ filter === "errors"
+ ? "bg-rose-500/20 text-rose-300 border border-rose-500/30"
+ : "text-slate-400 hover:text-white"
+ }`}
+ >
+ Errors Only
+
+ onFilterChange("slow")}
+ className={`rounded-lg px-2.5 py-1 text-[11px] font-bold transition-all ${
+ filter === "slow"
+ ? "bg-amber-500/20 text-amber-300 border border-amber-500/30"
+ : "text-slate-400 hover:text-white"
+ }`}
+ >
+ >200ms
+
+
+
+ {/* Service Filter */}
+ {allServices.length > 1 && (
+
+
+ onServiceFilterChange(e.target.value)}
+ className="bg-transparent text-[11px] font-bold text-slate-300 focus:outline-none cursor-pointer"
+ >
+
+ All Services
+
+ {allServices.map((s) => (
+
+ {s}
+
+ ))}
+
+
+ )}
+
+ {/* Sort Dropdown */}
+
+
+
onSortByChange(e.target.value)}
+ className="bg-transparent text-[11px] font-bold text-slate-300 focus:outline-none cursor-pointer"
+ >
+
+ Most Recent
+
+
+ Longest Duration
+
+
+ Shortest Duration
+
+
+ Most Errors
+
+
+ Most Spans
+
+
+
+
+
+
+ {/* Traces Table */}
+ {traces.length === 0 ? (
+
+ No traces match your filter or search criteria.
+
+ ) : (
+
+
+
+
+ Status
+ Trace / Operation
+ Services
+ Trace ID
+ Spans
+ Duration
+ Time
+ Action
+
+
+
+ {traces.map((trace) => {
+ const isSelected = trace.id === activeTraceId;
+ const rootService = trace.spans[0]?.service || trace.services[0] || "unknown";
+ const palette = getServicePalette(rootService);
+ const durationPercent = Math.max(
+ 4,
+ Math.min(100, (trace.duration / maxDuration) * 100),
+ );
+
+ return (
+ {
+ onSelectTrace(trace.id);
+ onSwitchToWaterfall();
+ }}
+ className={`cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-sky-500/15 border-l-4 border-sky-400"
+ : "hover:bg-white/[0.03]"
+ }`}
+ >
+
+ {trace.hasError ? (
+
+
+ {trace.errorCount} Error{trace.errorCount > 1 ? "s" : ""}
+
+ ) : (
+
+ OK
+
+ )}
+
+
+
+
+
+ {trace.name}
+
+
+ root: {trace.spans[0]?.name || trace.name}
+
+
+
+
+
+
+
+ {rootService}
+
+ {trace.services.length > 1 && (
+
+ +{trace.services.length - 1}
+
+ )}
+
+
+
+
+
+ {trace.id.slice(0, 10)}...
+ copyTraceId(trace.id, e)}
+ className="p-1 hover:text-white text-slate-500"
+ title="Copy Trace ID"
+ >
+ {copiedId === trace.id ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {trace.spans.length}
+
+
+
+
+ {formatDuration(trace.duration)}
+
+
+
+
+
+
+
+
+ {formatRelativeTime(trace.startTime / 1_000_000)}
+
+
+ {new Date(trace.startTime / 1_000_000).toLocaleTimeString()}
+
+
+
+
+
+ {
+ e.stopPropagation();
+ onSelectTrace(trace.id);
+ onSwitchToWaterfall();
+ }}
+ className="inline-flex items-center gap-1 rounded-xl bg-sky-500/10 hover:bg-sky-500/20 text-sky-400 px-3 py-1 text-xs font-bold transition-all border border-sky-500/20"
+ >
+ Waterfall
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+};
+
+export interface TraceVisualizationProps {
+ traces: TraceData[] | undefined;
+}
+
+export const TraceVisualization = ({ traces }: TraceVisualizationProps) => {
+ const safeTraces = traces ?? [];
+ const {
+ logCountBySpanId,
+ activeTraceId,
+ setActiveTraceId,
+ selectedTimeRange,
+ setSelectedTimeRange,
+ } = useObservabilityData();
+
+ const [viewMode, setViewMode] = useState<"waterfall" | "directory">("waterfall");
+ const [traceSearchQuery, setTraceSearchQuery] = useState("");
+ const [traceFilter, setTraceFilter] = useState<"all" | "errors" | "slow">("all");
+ const [traceServiceFilter, setTraceServiceFilter] = useState("all");
+ const [traceSortBy, setTraceSortBy] = useState<
+ "recent" | "duration-desc" | "duration-asc" | "errors" | "spans"
+ >("recent");
+
+ const orderedTraces: ProcessedTrace[] = useMemo(() => {
+ const spanAccumulator: ProcessedSpan[] = safeTraces.flatMap((trace) =>
+ trace.resourceSpans?.flatMap((resourceSpan) =>
+ resourceSpan.scopeSpans?.flatMap((scopeSpan) =>
+ scopeSpan.spans?.map((spanItem) => {
+ const { attributes, ...spanDetails } = spanItem;
+ const formattedAttributes = Object.fromEntries(
+ attributes?.map(({ key, value }) => [key, formatAttributeValue(value)]) ?? [],
+ );
+ const serviceName =
+ resourceSpan.resource?.attributes?.find(
+ (attr) => attr.key === "service.name",
+ )?.value.stringValue || "unknown-service";
+
+ const startTime = Number(spanDetails.startTimeUnixNano ?? "0");
+ const endTime = Number(
+ spanDetails.endTimeUnixNano ?? spanDetails.startTimeUnixNano ?? "0",
+ );
+ const duration = Math.max(endTime - startTime, 0);
+
+ const statusCode = formattedAttributes["http.status_code"];
+ const isHttpError = Boolean(statusCode && Number(statusCode) >= 400);
+ const isOtelError = (spanDetails.status as any)?.code === 2;
+ const hasError = isHttpError || isOtelError;
+
+ const logCount = logCountBySpanId[spanDetails.spanId] || 0;
+
+ return {
+ ...spanDetails,
+ attributes,
+ formattedAttributes,
+ service: serviceName,
+ startTime,
+ endTime,
+ duration,
+ logCount,
+ hasError,
+ } as ProcessedSpan;
+ }),
+ ),
+ ),
+ );
+
+ const uniqueSpans = spanAccumulator.filter(
+ (span, index, arr) =>
+ arr.findIndex((candidate) => candidate.spanId === span.spanId) === index,
+ );
+
+ const traceGroups = uniqueSpans.reduce>((acc, span) => {
+ if (!acc[span.traceId]) acc[span.traceId] = [];
+ acc[span.traceId].push(span);
+ return acc;
+ }, {});
+
+ const traceList: ProcessedTrace[] = Object.entries(traceGroups).map(([id, spans]) => {
+ const orderedSpans = spans.sort((a, b) => a.startTime - b.startTime);
+ const displayName =
+ orderedSpans.find((span) => span.name)?.name || `Trace ${id.slice(0, 8)}`;
+ const startTime = orderedSpans.reduce(
+ (min, s) => Math.min(min, s.startTime),
+ Number.POSITIVE_INFINITY,
+ );
+ const endTime = orderedSpans.reduce(
+ (max, s) => Math.max(max, s.endTime),
+ Number.NEGATIVE_INFINITY,
+ );
+ const duration = Math.max(endTime - startTime, 0);
+ const services = Array.from(new Set(orderedSpans.map((s) => s.service)));
+ const errorCount = orderedSpans.filter((s) => s.hasError).length;
+
+ return {
+ id,
+ name: displayName,
+ spans: orderedSpans,
+ startTime,
+ endTime,
+ duration,
+ services,
+ hasError: errorCount > 0,
+ errorCount,
+ };
+ });
+
+ // Sort traces by most recent first
+ traceList.sort((a, b) => b.startTime - a.startTime);
+ return traceList;
+ }, [safeTraces, logCountBySpanId]);
+
+ const allServices = useMemo(() => {
+ const s = new Set();
+ orderedTraces.forEach((t) => t.services.forEach((srv) => s.add(srv)));
+ return Array.from(s).sort();
+ }, [orderedTraces]);
+
+ const filteredTraceList = useMemo(() => {
+ return orderedTraces
+ .filter((t) => {
+ if (traceFilter === "errors" && !t.hasError) return false;
+ if (traceFilter === "slow" && t.duration / 1_000_000 < 200) return false;
+ if (traceServiceFilter !== "all" && !t.services.includes(traceServiceFilter)) return false;
+ if (traceSearchQuery.trim()) {
+ const q = traceSearchQuery.toLowerCase().trim();
+ const matchName = t.name.toLowerCase().includes(q);
+ const matchId = t.id.toLowerCase().includes(q);
+ const matchService = t.services.some((s) => s.toLowerCase().includes(q));
+ if (!matchName && !matchId && !matchService) return false;
+ }
+ return true;
+ })
+ .sort((a, b) => {
+ if (traceSortBy === "duration-desc") return b.duration - a.duration;
+ if (traceSortBy === "duration-asc") return a.duration - b.duration;
+ if (traceSortBy === "errors")
+ return b.errorCount - a.errorCount || b.startTime - a.startTime;
+ if (traceSortBy === "spans") return b.spans.length - a.spans.length;
+ return b.startTime - a.startTime; // recent
+ });
+ }, [orderedTraces, traceFilter, traceServiceFilter, traceSearchQuery, traceSortBy]);
+
+ // Set default active trace if none selected or if active trace is outside current filtered list
+ useEffect(() => {
+ if (filteredTraceList.length > 0) {
+ if (!activeTraceId || !filteredTraceList.some((t) => t.id === activeTraceId)) {
+ setActiveTraceId(filteredTraceList[0].id);
+ }
+ }
+ }, [filteredTraceList, activeTraceId, setActiveTraceId]);
+
+ const currentTraceIndex = useMemo(() => {
+ return filteredTraceList.findIndex((t) => t.id === activeTraceId);
+ }, [filteredTraceList, activeTraceId]);
+
+ const handlePrevTrace = () => {
+ if (currentTraceIndex > 0) {
+ setActiveTraceId(filteredTraceList[currentTraceIndex - 1].id);
+ }
+ };
+
+ const handleNextTrace = () => {
+ if (currentTraceIndex < filteredTraceList.length - 1) {
+ setActiveTraceId(filteredTraceList[currentTraceIndex + 1].id);
+ }
+ };
+
+ if (orderedTraces.length === 0) {
+ return (
+
+
+
+
No traces match your filters
+
+ {selectedTimeRange
+ ? "No traces in the focused waveform period."
+ : "Adjust focus in Global filters or click Reset Configuration."}
+
+ {selectedTimeRange && (
+
setSelectedTimeRange(null)}
+ className="mt-3 inline-flex items-center gap-1.5 rounded-xl bg-sky-500/20 px-3 py-1.5 text-xs font-bold text-sky-300 border border-sky-400/30 hover:bg-sky-500/30 transition-all"
+ >
+
+ Clear Waveform Period Filter
+
+ )}
+
+
+ );
+ }
+
+ // Active trace or first trace
+ const activeTrace =
+ filteredTraceList.find((t) => t.id === activeTraceId) ||
+ orderedTraces.find((t) => t.id === activeTraceId) ||
+ orderedTraces[0];
+
+ return (
+
+ {/* Waveform Focus Notification Banner if Active */}
+ {selectedTimeRange && (
+
+
+
+
+ Focused on waveform period:{" "}
+ {selectedTimeRange.label} (
+ {orderedTraces.length} trace{orderedTraces.length !== 1 ? "s" : ""} active)
+
+
+
setSelectedTimeRange(null)}
+ className="flex items-center gap-1 rounded-lg bg-sky-500/20 px-2.5 py-1 font-mono text-[11px] font-bold text-sky-300 hover:bg-sky-500/30 transition-colors"
+ >
+
+ Clear Focus
+
+
+ )}
+
+ {/* View Switcher Bar (Waterfall vs Directory Table) */}
+
+
+ {/* Mode Switcher */}
+
+
setViewMode("directory")}
+ className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-bold transition-all ${
+ viewMode === "directory"
+ ? "bg-sky-500/20 text-sky-300 shadow-sm"
+ : "text-slate-400 hover:text-white"
+ }`}
+ title="View all traces in a searchable, sortable directory"
+ >
+
+ All Traces ({filteredTraceList.length})
+
+
setViewMode("waterfall")}
+ className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-bold transition-all ${
+ viewMode === "waterfall"
+ ? "bg-sky-500/20 text-sky-300 shadow-sm"
+ : "text-slate-400 hover:text-white"
+ }`}
+ title="Inspect single trace waterfall and spans"
+ >
+
+ Waterfall Detail
+
+
+
+ {/* In Waterfall Mode: Prev / Next Stepper & Searchable Dropdown */}
+ {viewMode === "waterfall" && filteredTraceList.length > 1 && (
+ <>
+
+
+
+
+
+ {currentTraceIndex >= 0 ? currentTraceIndex + 1 : 1} / {filteredTraceList.length}
+
+ = filteredTraceList.length - 1}
+ className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:pointer-events-none transition-all"
+ title="Next trace"
+ >
+
+
+
+
+ {/* Searchable Trace Picker Dropdown */}
+
setActiveTraceId(id)}
+ />
+ >
+ )}
+
+
+ {/* Trace Quick Stats Indicator */}
+
+ {filteredTraceList.length} traces available
+
+
+
+ {/* Main View: Directory Table or Waterfall */}
+ {viewMode === "directory" ? (
+
setActiveTraceId(id)}
+ searchQuery={traceSearchQuery}
+ onSearchChange={setTraceSearchQuery}
+ filter={traceFilter}
+ onFilterChange={setTraceFilter}
+ serviceFilter={traceServiceFilter}
+ onServiceFilterChange={setTraceServiceFilter}
+ sortBy={traceSortBy}
+ onSortByChange={setTraceSortBy}
+ allServices={allServices}
+ onSwitchToWaterfall={() => setViewMode("waterfall")}
+ />
+ ) : (
+
+ )}
+
+ );
+};
+
+export default TraceVisualization;
+
diff --git a/otel-ui/src/components/TrendsVisualizer.tsx b/otel-ui/src/components/TrendsVisualizer.tsx
new file mode 100644
index 0000000..a3b3214
--- /dev/null
+++ b/otel-ui/src/components/TrendsVisualizer.tsx
@@ -0,0 +1,323 @@
+import { useMemo, useState } from "react"
+import {
+ Area,
+ AreaChart,
+ CartesianGrid,
+ Legend,
+ Line,
+ LineChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts"
+import { AlertOctagon, GitCompareArrows, RefreshCw, Server } from "lucide-react"
+import { useStats } from "../hooks/useStats"
+import type { StatsPoint } from "../lib/types"
+
+const compactFormatter = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ maximumFractionDigits: 1,
+})
+
+// Stable palette for release lines; cycles when there are many releases.
+const RELEASE_COLORS = ["#38bdf8", "#f472b6", "#34d399", "#fbbf24", "#a78bfa", "#fb7185"]
+
+const label = (bucket: string, granularity: "hour" | "day") => {
+ const d = new Date(bucket.replace(" ", "T") + "Z")
+ if (Number.isNaN(d.getTime())) return bucket
+ return granularity === "day"
+ ? d.toLocaleDateString([], { month: "short", day: "numeric" })
+ : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false })
+}
+
+const ChartTooltip = ({ active, payload, granularity }: any) => {
+ if (!active || !payload?.length) return null
+ const p = payload[0].payload
+ const d = new Date(String(p.bucket).replace(" ", "T") + "Z")
+ return (
+
+
+ {d.toLocaleString([], {
+ month: "short",
+ day: "numeric",
+ hour: granularity === "hour" ? "2-digit" : undefined,
+ minute: granularity === "hour" ? "2-digit" : undefined,
+ hour12: false,
+ })}
+
+ {payload.map((entry: any) => (
+
+
+
+ {entry.name}
+
+ {entry.value}
+
+ ))}
+
+ )
+}
+
+const rangeOptions = [24, 72, 168, 720] // 1d, 3d, 7d, 30d
+
+export const TrendsVisualizer = () => {
+ const [hours, setHours] = useState(72)
+ const [service, setService] = useState("")
+ const { errorPoints, granularity, releaseSeries, services, loading, error, refresh } = useStats({
+ hours,
+ service,
+ })
+
+ const errorData = useMemo(
+ () =>
+ errorPoints.map((p: StatsPoint) => ({
+ bucket: p.bucket,
+ total: p.total,
+ errors: p.errors,
+ })),
+ [errorPoints],
+ )
+
+ const releases = useMemo(() => Object.keys(releaseSeries).sort(), [releaseSeries])
+ const releaseData = useMemo(() => {
+ // Recharts wants rows, not per-series arrays: bucket -> {release: errors}.
+ const byBucket: Record> = {}
+ releases.forEach((rel) => {
+ releaseSeries[rel].forEach((p) => {
+ byBucket[p.bucket] = byBucket[p.bucket] || { bucket: p.bucket }
+ byBucket[p.bucket][rel] = p.errors
+ })
+ })
+ return Object.values(byBucket).sort((a, b) =>
+ String(a.bucket).localeCompare(String(b.bucket)),
+ )
+ }, [releaseSeries, releases])
+
+ const totals = useMemo(() => {
+ let total = 0
+ let errors = 0
+ errorPoints.forEach((p) => {
+ total += p.total
+ errors += p.errors
+ })
+ return { total, errors }
+ }, [errorPoints])
+
+ if (loading && errorPoints.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* Toolbar */}
+
+
+
+
+ Historical Trends
+
+
+ {rangeOptions.map((h) => (
+ setHours(h)}
+ className={`rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider transition-all ${
+ hours === h
+ ? "bg-sky-500/20 text-sky-300 ring-1 ring-sky-500/30"
+ : "text-slate-500 hover:text-slate-300"
+ }`}
+ >
+ {h < 24 ? `${h}h` : `${h / 24}d`}
+
+ ))}
+
+ {services.length > 0 && (
+
setService(e.target.value)}
+ className="rounded-lg border border-white/10 bg-slate-900/60 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-300 outline-none"
+ aria-label="Filter by service"
+ >
+ All services
+ {services.map((s) => (
+
+ {s}
+
+ ))}
+
+ )}
+
+
+
+ {compactFormatter.format(totals.total)} entries ·{" "}
+ 0 ? "text-rose-400" : "text-emerald-400"}>
+ {compactFormatter.format(totals.errors)} errors
+
+
+ refresh()}
+ className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 transition-all hover:border-white/20 hover:text-white"
+ >
+
+ Refresh
+
+
+
+
+ {error && (
+
+ )}
+
+ {/* Error trend */}
+
+
+
+
+ Entries & errors over time
+
+
+ per {granularity}
+
+
+ {errorData.length === 0 ? (
+
+ No data in this range yet.
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ label(b, granularity)}
+ stroke="#475569"
+ fontSize={10}
+ tickLine={false}
+ axisLine={false}
+ minTickGap={40}
+ />
+ compactFormatter.format(v)}
+ />
+ } />
+
+
+
+
+
+
+ )}
+
+
+ {/* Release comparison */}
+
+
+
+
+ Errors by release
+
+
+ {releases.length} release{releases.length === 1 ? "" : "s"} · overlay
+
+
+ {releaseData.length === 0 || releases.length === 0 ? (
+
+ No release-tagged telemetry in this range. Have services send{" "}
+ service.release{" "}
+ to compare deploys here.
+
+ ) : (
+
+
+
+
+ label(b, "hour")}
+ stroke="#475569"
+ fontSize={10}
+ tickLine={false}
+ axisLine={false}
+ minTickGap={40}
+ />
+ compactFormatter.format(v)}
+ />
+ } />
+
+ {releases.map((rel, i) => (
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ Served from the ClickHouse hourly rollup ({service || "all services"}, last {hours}h)
+
+
+ )
+}
+
+export default TrendsVisualizer
diff --git a/otel-ui/src/components/ViewErrorBoundary.tsx b/otel-ui/src/components/ViewErrorBoundary.tsx
new file mode 100644
index 0000000..0259a21
--- /dev/null
+++ b/otel-ui/src/components/ViewErrorBoundary.tsx
@@ -0,0 +1,62 @@
+import { Component, type ErrorInfo, type ReactNode } from "react"
+import { AlertTriangle, RotateCcw } from "lucide-react"
+
+interface Props {
+ children: ReactNode
+}
+
+interface State {
+ error: Error | null
+}
+
+// Keeps a rendering bug in one telemetry view from blanking the whole app.
+export class ViewErrorBoundary extends Component {
+ state: State = { error: null }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { error }
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ console.error("View crashed:", error, info.componentStack)
+ const rum = (window as Window & {
+ OtelRUM?: {
+ last?: {
+ captureException: (error: Error, extra?: Record) => void
+ }
+ }
+ }).OtelRUM?.last
+ rum?.captureException(error, {
+ "exception.type": "ReactErrorBoundary",
+ "react.component_stack": info.componentStack,
+ })
+ }
+
+ render() {
+ if (this.state.error) {
+ return (
+
+
+
+
+ This view hit an unexpected error
+
+
+ {this.state.error.message}
+
+
+
this.setState({ error: null })}
+ className="inline-flex items-center gap-2 rounded-xl border border-rose-500/30 bg-rose-500/10 px-4 py-2 text-[10px] font-black uppercase tracking-[0.2em] text-rose-300 transition-all hover:bg-rose-500/20"
+ >
+
+ Retry
+
+
+ )
+ }
+
+ return this.props.children
+ }
+}
diff --git a/otel-ui/src/components/WaveformHistogram.tsx b/otel-ui/src/components/WaveformHistogram.tsx
new file mode 100644
index 0000000..f2745c7
--- /dev/null
+++ b/otel-ui/src/components/WaveformHistogram.tsx
@@ -0,0 +1,385 @@
+import { useMemo, useState } from "react";
+import { useObservabilityData } from "../hooks/useObservabilityData";
+import { isSpanError } from "../lib/otel-utils";
+
+export const WaveformHistogram = () => {
+ const {
+ logs,
+ traces,
+ unfocusedLogs,
+ unfocusedTraces,
+ selectedTimeRange,
+ setSelectedTimeRange,
+ setActiveTab,
+ } = useObservabilityData();
+
+ // Use unfocused data so that waveform buckets remain stable across the window
+ const sourceLogs = unfocusedLogs || logs;
+ const sourceTraces = unfocusedTraces || traces;
+
+ const { buckets, hasErrorSurge, surgePercent, timeLabels, totalCount } = useMemo(() => {
+ const numBuckets = 24;
+ const bucketStats = Array.from({ length: numBuckets }, () => ({
+ error: 0,
+ warn: 0,
+ info: 0,
+ total: 0,
+ traceCount: 0,
+ logCount: 0,
+ }));
+
+ // Collect timestamps and severity levels from real telemetry items
+ const items: Array<{ time: number; level: "error" | "warn" | "info"; isTrace?: boolean }> = [];
+
+ sourceLogs.forEach((log) => {
+ (log.scopeLogs ?? []).forEach((sl) => {
+ (sl.logRecords ?? []).forEach((r) => {
+ const t = Number(BigInt(r.timeUnixNano || "0") / 1_000_000n);
+ if (t > 0) {
+ const sev = r.severityNumber ?? 0;
+ const level = sev >= 17 ? "error" : sev >= 13 ? "warn" : "info";
+ items.push({ time: t, level, isTrace: false });
+ }
+ });
+ });
+ });
+
+ sourceTraces.forEach((trace) => {
+ (trace.resourceSpans ?? []).forEach((rs) => {
+ (rs.scopeSpans ?? []).forEach((ss) => {
+ (ss.spans ?? []).forEach((s) => {
+ const t = Number(BigInt(s.startTimeUnixNano || "0") / 1_000_000n);
+ if (t > 0) {
+ const hasErr = isSpanError(s);
+ items.push({ time: t, level: hasErr ? "error" : "info", isTrace: true });
+ }
+ });
+ });
+ });
+ });
+
+ if (items.length === 0) {
+ return {
+ buckets: [],
+ hasErrorSurge: false,
+ surgePercent: 0,
+ timeLabels: [],
+ totalCount: 0,
+ };
+ }
+
+ const minTime = Math.min(...items.map((it) => it.time));
+ const maxTime = Math.max(...items.map((it) => it.time));
+ const range = Math.max(maxTime - minTime, 1000);
+
+ items.forEach((it) => {
+ const idx = Math.min(numBuckets - 1, Math.floor(((it.time - minTime) / range) * numBuckets));
+ bucketStats[idx][it.level] += 1;
+ bucketStats[idx].total += 1;
+ if (it.isTrace) {
+ bucketStats[idx].traceCount += 1;
+ } else {
+ bucketStats[idx].logCount += 1;
+ }
+ });
+
+ // Check for surge in real errors
+ let maxErrors = 0;
+ let baselineErrors = 0;
+ bucketStats.forEach((b, idx) => {
+ if (b.error > maxErrors) maxErrors = b.error;
+ if (idx < numBuckets / 2) baselineErrors += b.error;
+ });
+
+ const avgBaseline = baselineErrors / (numBuckets / 2) || 1;
+ const computedSurge = Math.round(((maxErrors - avgBaseline) / avgBaseline) * 100);
+
+ const timeLabels = [
+ new Date(minTime).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }),
+ new Date(minTime + range * 0.33).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }),
+ new Date(minTime + range * 0.66).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }),
+ new Date(maxTime).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }),
+ ];
+
+ // Normalize heights to percentage
+ const maxBucketTotal = Math.max(...bucketStats.map((b) => b.total), 1);
+ const normalized = bucketStats.map((b, idx) => {
+ const scale = (b.total / maxBucketTotal) * 100;
+ const errorPct = b.total > 0 ? (b.error / b.total) * scale : 0;
+ const warnPct = b.total > 0 ? (b.warn / b.total) * scale : 0;
+ const infoPct = b.total > 0 ? (b.info / b.total) * scale : 0;
+
+ const startTime = minTime + Math.floor((idx * range) / numBuckets);
+ const endTime = minTime + Math.floor(((idx + 1) * range) / numBuckets);
+ const timeLabel = `${new Date(startTime).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })} – ${new Date(endTime).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })}`;
+
+ return {
+ idx,
+ error: errorPct,
+ warn: warnPct,
+ info: infoPct,
+ rawError: b.error,
+ rawWarn: b.warn,
+ rawInfo: b.info,
+ total: b.total,
+ traceCount: b.traceCount,
+ logCount: b.logCount,
+ startTime,
+ endTime,
+ timeLabel,
+ };
+ });
+
+ return {
+ buckets: normalized,
+ hasErrorSurge: maxErrors >= 5 && computedSurge > 50,
+ surgePercent: computedSurge,
+ timeLabels,
+ totalCount: items.length,
+ };
+ }, [sourceLogs, sourceTraces]);
+
+ const [isCollapsed, setIsCollapsed] = useState(false);
+
+ // Check which bucket matches the active selectedTimeRange
+ const activeBucket = useMemo(() => {
+ if (!selectedTimeRange || buckets.length === 0) return null;
+ return (
+ buckets.find(
+ (b) =>
+ selectedTimeRange.startMs <= b.endTime && selectedTimeRange.endMs >= b.startTime,
+ ) || null
+ );
+ }, [selectedTimeRange, buckets]);
+
+ const handleBucketClick = (b: (typeof buckets)[0]) => {
+ if (activeBucket && activeBucket.idx === b.idx) {
+ // Toggle off if clicking the already selected bucket
+ setSelectedTimeRange(null);
+ } else {
+ setSelectedTimeRange({
+ startMs: b.startTime,
+ endMs: b.endTime,
+ label: b.timeLabel,
+ });
+ }
+ };
+
+ if (buckets.length === 0) {
+ return (
+
+
+
+
+ monitoring
+
+
+ Telemetry Volume & Error Waveform
+
+
+
Listening for OTLP traffic...
+
+
+ );
+ }
+
+ return (
+
+
+
+ setIsCollapsed(!isCollapsed)}
+ className="flex items-center gap-1.5 hover:text-white transition-colors"
+ >
+
+ monitoring
+
+
+ Telemetry Volume Waveform
+
+
+ {isCollapsed ? "expand_more" : "expand_less"}
+
+
+
+ {totalCount} events in current buffer
+
+ {hasErrorSurge && (
+
+
+ SURGE +{surgePercent}%
+
+ )}
+ {selectedTimeRange && (
+
+
+ Time Focus Active
+ {
+ e.stopPropagation();
+ setSelectedTimeRange(null);
+ }}
+ className="ml-1 hover:text-white"
+ title="Clear time focus"
+ >
+ ✕
+
+
+ )}
+
+
+ {/* Legend */}
+
+
+
+ ERROR
+
+
+
+ WARN
+
+
+
+ INFO
+
+
+
+
+ {!isCollapsed && (
+ <>
+ {/* Stacked SVG Histogram with Real Traffic */}
+
+
+ {buckets.map((b) => {
+ const isSurgeBar = hasErrorSurge && b.error > 40;
+ const isSelected = activeBucket?.idx === b.idx;
+ const hasAnySelected = activeBucket !== null;
+
+ return (
+
handleBucketClick(b)}
+ className={`flex-1 flex flex-col justify-end h-full gap-0.5 group cursor-pointer relative transition-all rounded-sm ${
+ isSelected
+ ? "ring-2 ring-sky-400 bg-sky-400/25 scale-y-105 z-20 shadow-md shadow-sky-500/20"
+ : hasAnySelected
+ ? "opacity-40 hover:opacity-100 hover:brightness-125"
+ : "hover:brightness-125"
+ }`}
+ title={`${b.timeLabel}\nTraces: ${b.traceCount} • Logs: ${b.logCount}\nErrors: ${b.rawError}, Warns: ${b.rawWarn}, Info: ${b.rawInfo}\nClick to ${isSelected ? "clear focus" : "focus and show events for this period"}`}
+ >
+ {/* Active Selection Focus Pin Indicator */}
+ {isSelected && (
+
+
+ FOCUS
+
+
+
+ )}
+
+ {/* Incident Flag Marker on Real Surge */}
+ {!isSelected && isSurgeBar && b.idx === 15 && (
+
+
+
+ SURGE +{surgePercent}%
+
+
+
+ )}
+
+ {/* Error segment */}
+ {b.error > 0 && (
+
+ )}
+
+ {/* Warn segment */}
+ {b.warn > 0 && (
+
+ )}
+
+ {/* Info segment */}
+ {b.info > 0 && (
+
+ )}
+
+ );
+ })}
+
+
+
+ {/* Real Time Scale Axes */}
+
+ {timeLabels[0]}
+ {timeLabels[1]}
+
+ {timeLabels[2]} {hasErrorSurge ? "(SURGE)" : ""}
+
+ {timeLabels[3]}
+
+
+ {/* Time Focus Bar with quick tab navigation and clear button */}
+ {activeBucket && (
+
+
+
+
+
+ Focused Time Period:
+
+
+ {activeBucket.timeLabel}
+
+
+ ({activeBucket.traceCount} trace events • {activeBucket.logCount} log records
+ {activeBucket.rawError > 0 ? ` • ${activeBucket.rawError} errors` : ""})
+
+
+
+
+
+ setActiveTab("tab1")}
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-sky-500/20 hover:bg-sky-500/30 text-sky-300 font-bold transition-all border border-sky-400/30"
+ >
+ timeline
+ Show Traces ({activeBucket.traceCount})
+
+ setActiveTab("tab3")}
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-sky-500/20 hover:bg-sky-500/30 text-sky-300 font-bold transition-all border border-sky-400/30"
+ >
+ receipt_long
+ Show Logs ({activeBucket.logCount})
+
+ setSelectedTimeRange(null)}
+ className="flex items-center gap-1 px-2 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-slate-300 transition-colors"
+ title="Clear time focus"
+ >
+ close
+ Clear Focus
+
+
+
+ )}
+ >
+ )}
+
+ );
+};
diff --git a/otel-ui/src/contexts/ObservabilityContextProvider.tsx b/otel-ui/src/contexts/ObservabilityContextProvider.tsx
new file mode 100644
index 0000000..b3b7d6e
--- /dev/null
+++ b/otel-ui/src/contexts/ObservabilityContextProvider.tsx
@@ -0,0 +1,799 @@
+"use client"
+
+import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from "react"
+import { useLocation } from "wouter"
+import { tabForPath, pathForTab } from "../lib/routes"
+import type {
+ MetricData,
+ OtelHistoryMessage,
+ OtelMessage,
+ LogEntry,
+ TraceData,
+ ResourceSpan,
+ ScopeSpan,
+ Span,
+} from "../lib/types"
+import { ObservabilityContext, type TimeRangeFilter } from "./ObservabilityContextValue"
+import { getServiceName, formatAttributeValue, isSpanError } from "../lib/otel-utils"
+import { partitionMessages, mergeLogsByTraceId } from "../lib/ingest"
+import { authFetch, tokens } from "../lib/auth"
+
+interface ObservabilityProviderProps {
+ children: ReactNode
+}
+
+export function ObservabilityProvider({
+ children,
+}: ObservabilityProviderProps) {
+ const [logsByTraceId, setLogsByTraceId] = useState<
+ Record
+ >({})
+ const [traces, setTraces] = useState([])
+ const [logs, setLogs] = useState([])
+ const [metrics, setMetrics] = useState([])
+
+ const [isConnected, setIsConnected] = useState(false)
+ const wsRef = useRef(null)
+ const reconnectTimeoutRef = useRef(null)
+
+ // --- Real Streaming / Pause Buffer State ---
+ const [isStreaming, setIsStreaming] = useState(true)
+ const isStreamingRef = useRef(true)
+ const bufferedMessagesRef = useRef([])
+ const [bufferedCount, setBufferedCount] = useState(0)
+
+ // --- Issues live update counter ---
+ const [issuesVersion, setIssuesVersion] = useState(0)
+
+ // --- Latency filter (milliseconds) ---
+ const [minDurationMs, setMinDurationMs] = useState(0)
+
+ // --- Persistence Keys ---
+ const SERVICES_STORAGE_KEY = "otel_selected_services"
+ const ATTRIBUTES_STORAGE_KEY = "otel_excluded_attributes"
+ const SHOW_ERRORS_STORAGE_KEY = "otel_show_errors_only"
+ const SEARCH_QUERY_STORAGE_KEY = "otel_search_query"
+ const TIME_WINDOW_STORAGE_KEY = "otel_time_window"
+
+ // --- Time Window Filter ---
+ const [timeWindow, setTimeWindow] = useState<"15m" | "1h" | "6h" | "24h" | "all">(() => {
+ if (typeof window === "undefined") return "15m"
+ return (localStorage.getItem(TIME_WINDOW_STORAGE_KEY) as any) || "15m"
+ })
+
+ // --- Selected Time Period Filter (e.g. from clicking waveform) ---
+ const [selectedTimeRange, setSelectedTimeRange] = useState(null)
+
+ // --- Filtering State ---
+ const defaultExcludedAttributes = useCallback((): Record> => ({
+ "user_agent.original": new Set([
+ "kube-probe/1.33",
+ "kube-probe/1.31",
+ "Uptime-Kuma/1.23.11",
+ ]),
+ "http.target": new Set(["/healthz", "/readyz", "/livez"]),
+ "http.host": new Set(["git.k6n.net"]),
+ "http.user_agent": new Set(["Uptime-Kuma/1.23.11"]),
+ }), [])
+
+ const [selectedServices, setSelectedServices] = useState>(() => {
+ if (typeof window === "undefined") return new Set()
+ const saved = localStorage.getItem(SERVICES_STORAGE_KEY)
+ return saved ? new Set(JSON.parse(saved)) : new Set()
+ })
+
+ const [excludedAttributes, setExcludedAttributes] = useState>>(() => {
+ if (typeof window === "undefined") return defaultExcludedAttributes()
+ const saved = localStorage.getItem(ATTRIBUTES_STORAGE_KEY)
+ if (!saved) return defaultExcludedAttributes()
+ try {
+ const parsed = JSON.parse(saved)
+ const restored: Record> = {}
+ Object.entries(parsed).forEach(([key, values]) => {
+ restored[key] = new Set(values as string[])
+ })
+ return restored
+ } catch (e) {
+ console.error("Failed to restore excluded attributes", e)
+ return defaultExcludedAttributes()
+ }
+ })
+
+ const [showErrorsOnly, setShowErrorsOnly] = useState(() => {
+ if (typeof window === "undefined") return false
+ const saved = localStorage.getItem(SHOW_ERRORS_STORAGE_KEY)
+ return saved === "true"
+ })
+
+ const [searchQuery, setSearchQuery] = useState(() => {
+ if (typeof window === "undefined") return ""
+ return localStorage.getItem(SEARCH_QUERY_STORAGE_KEY) || ""
+ })
+
+ // --- Persistence Effect ---
+ useEffect(() => {
+ if (typeof window === "undefined") return
+ localStorage.setItem(SERVICES_STORAGE_KEY, JSON.stringify(Array.from(selectedServices)))
+
+ const serializeable: Record = {}
+ Object.entries(excludedAttributes).forEach(([key, values]) => {
+ serializeable[key] = Array.from(values)
+ })
+ localStorage.setItem(ATTRIBUTES_STORAGE_KEY, JSON.stringify(serializeable))
+ localStorage.setItem(SHOW_ERRORS_STORAGE_KEY, showErrorsOnly.toString())
+ localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, searchQuery)
+ localStorage.setItem(TIME_WINDOW_STORAGE_KEY, timeWindow)
+ }, [selectedServices, excludedAttributes, showErrorsOnly, searchQuery, timeWindow])
+
+
+ // --- Selection State ---
+ const [activeTraceId, setActiveTraceId] = useState(null)
+ const [activeSpanId, setActiveSpanId] = useState(null)
+ // Tab state lives in the URL (wouter): /issues, /traces, ... so deep
+ // links survive the OIDC login redirect and views are shareable. The
+ // state mirror below keeps every existing setActiveTab call site working;
+ // it follows the URL on navigation and pushes the URL on programmatic
+ // setActiveTab calls.
+ const [location, navigate] = useLocation()
+ const [activeTab, setActiveTab] = useState(
+ () => tabForPath(window.location.pathname) ?? "tab0",
+ )
+
+ // External navigation (back/forward, manual URL edit, login redirect):
+ // adopt the tab encoded in the new location.
+ useEffect(() => {
+ const tab = tabForPath(location)
+ if (tab && tab !== activeTab) setActiveTab(tab)
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is intentionally read stale here; see comment above
+ }, [location])
+
+ const setActiveTabWithRoute = useCallback(
+ (tab: string) => {
+ setActiveTab(tab)
+ const path = pathForTab(tab)
+ if (path && path !== window.location.pathname) navigate(path)
+ },
+ [navigate],
+ )
+ const [filtersExpanded, setFiltersExpanded] = useState(false)
+ // Trace ids we already have spans for, so selecting a trace that fell
+ // outside the initial history window (or arrived before this session
+ // connected) triggers an on-demand GET /api/traces/{traceId} instead of
+ // silently showing nothing.
+ const [knownTraceIds, setKnownTraceIds] = useState>(new Set())
+
+ const toggleService = useCallback((service: string) => {
+ setSelectedServices(prev => {
+ const next = new Set(prev)
+ if (next.has(service)) {
+ next.delete(service)
+ } else {
+ next.add(service)
+ }
+ return next
+ })
+ }, [])
+
+ const toggleExcludedValue = useCallback((key: string, value: string) => {
+ setExcludedAttributes((prev) => {
+ const currentSet = prev[key] ?? new Set()
+ const nextSet = new Set(currentSet)
+ if (nextSet.has(value)) {
+ nextSet.delete(value)
+ } else {
+ nextSet.add(value)
+ }
+ const next = { ...prev }
+ if (nextSet.size === 0) {
+ delete next[key]
+ } else {
+ next[key] = nextSet
+ }
+ return next
+ })
+ }, [])
+
+ const removeExcludedValue = useCallback((key: string, value: string) => {
+ setExcludedAttributes((prev) => {
+ const current = prev[key]
+ if (!current || !current.has(value)) {
+ return prev
+ }
+ const nextSet = new Set(current)
+ nextSet.delete(value)
+ const next = { ...prev }
+ if (nextSet.size === 0) {
+ delete next[key]
+ } else {
+ next[key] = nextSet
+ }
+ return next
+ })
+ }, [])
+
+ const clearAttributeKey = useCallback((key: string) => {
+ setExcludedAttributes((prev) => {
+ if (!prev[key]) {
+ return prev
+ }
+ const next = { ...prev }
+ delete next[key]
+ return next
+ })
+ }, [])
+
+ const resetFilters = useCallback(() => {
+ setSelectedServices(new Set())
+ setExcludedAttributes(defaultExcludedAttributes())
+ setSearchQuery("")
+ setSelectedTimeRange(null)
+ }, [defaultExcludedAttributes])
+
+ // Flush buffered WebSocket stream entries when resuming
+ const flushBufferedStream = useCallback(() => {
+ const queued = bufferedMessagesRef.current
+ bufferedMessagesRef.current = []
+ setBufferedCount(0)
+ if (queued.length > 0) {
+ const partitioned = partitionMessages(queued)
+ if (partitioned.traces.length) {
+ setTraces((prev) => [...prev, ...partitioned.traces])
+ }
+ if (partitioned.metrics.length) {
+ setMetrics((prev) => [...prev, ...partitioned.metrics])
+ }
+ if (partitioned.logs.length) {
+ setLogs((prev) => [...prev, ...partitioned.logs])
+ setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId))
+ }
+ if (partitioned.traceIds.size) {
+ setKnownTraceIds((prev) => {
+ const next = new Set(prev)
+ partitioned.traceIds.forEach((id) => next.add(id))
+ return next
+ })
+ }
+ }
+ }, [])
+
+ useEffect(() => {
+ isStreamingRef.current = isStreaming
+ if (isStreaming && bufferedMessagesRef.current.length > 0) {
+ flushBufferedStream()
+ }
+ }, [isStreaming, flushBufferedStream])
+
+ // Compute cutoff timestamp in nanoseconds for timeWindow
+ const cutoffNano = useMemo(() => {
+ if (timeWindow === "all") return 0n
+ const now = Date.now()
+ const windowMs = {
+ "15m": 15 * 60 * 1000,
+ "1h": 60 * 60 * 1000,
+ "6h": 6 * 60 * 60 * 1000,
+ "24h": 24 * 60 * 60 * 1000,
+ "all": 0,
+ }[timeWindow]
+ return BigInt(now - windowMs) * 1_000_000n
+ }, [timeWindow])
+
+ // Discover all unique services across all signals
+ const uniqueServices = useMemo(() => {
+ const services = new Set()
+
+ // Check logs
+ logs.forEach(log => {
+ services.add(getServiceName(log.resource?.attributes ?? []))
+ ;(log.scopeLogs ?? []).forEach(sl => (sl.logRecords ?? []).forEach(record => {
+ services.add(getServiceName(log.resource?.attributes ?? [], record.attributes ?? []))
+ }))
+ })
+
+ // Check traces
+ traces.forEach(trace => {
+ trace.resourceSpans?.forEach(resourceSpan => {
+ services.add(getServiceName(resourceSpan.resource?.attributes ?? []))
+ resourceSpan.scopeSpans?.forEach(ss => {
+ ss.spans?.forEach(s => {
+ services.add(getServiceName(resourceSpan.resource?.attributes ?? [], s.attributes ?? []))
+ })
+ })
+ })
+ })
+
+ // Check metrics
+ metrics.forEach(metric => {
+ metric.resourceMetrics?.forEach(rm => {
+ services.add(getServiceName(rm.resource?.attributes ?? []))
+ })
+ })
+
+ return Array.from(services).sort().filter(s => s !== "unknown-service")
+ }, [logs, traces, metrics])
+
+ // --- Base Filtered Data (Global Filters: Service, TimeWindow, Search, Excluded Attributes) ---
+ const unfocusedLogs = useMemo(() => {
+ return logs.map(log => {
+ const filteredScopeLogs = (log.scopeLogs ?? []).map(sl => {
+ const filteredRecords = (sl.logRecords ?? []).filter(record => {
+ const serviceName = getServiceName(log.resource?.attributes ?? [], record.attributes ?? [])
+ // Filter by service
+ if (selectedServices.size > 0 && !selectedServices.has(serviceName)) return false
+
+ // Filter by time window
+ if (cutoffNano > 0n) {
+ const tNano = BigInt(record.timeUnixNano || "0")
+ if (tNano > 0n && tNano < cutoffNano) return false
+ }
+
+ // Filter by attributes
+ const recordAttrs = Object.fromEntries((record.attributes ?? []).map(a => [a.key, formatAttributeValue(a.value)]))
+ const isExcluded = Object.entries(excludedAttributes).some(([key, values]) => {
+ const val = recordAttrs[key]
+ return val && values.has(val)
+ })
+ if (isExcluded) return false
+
+ if (showErrorsOnly) {
+ return (record.severityNumber ?? 0) >= 13 // 13 is Warn, 17 is Error
+ }
+
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase()
+ const matchInBody = record.body?.stringValue?.toLowerCase()?.includes(query) ?? false
+ const matchInAttrs = record.attributes?.some(a =>
+ a.key.toLowerCase()?.includes(query) ||
+ formatAttributeValue(a.value).toLowerCase()?.includes(query)
+ ) ?? false
+ const matchInServiceName = serviceName.toLowerCase()?.includes(query) ?? false
+ const matchInTrace = (record.traceId || "").toLowerCase().includes(query)
+ if (!matchInBody && !matchInAttrs && !matchInServiceName && !matchInTrace) return false
+ }
+
+ return true
+ })
+ if (filteredRecords.length === 0) return null
+ return { ...sl, logRecords: filteredRecords }
+ }).filter(Boolean)
+
+ if (filteredScopeLogs.length === 0) return null
+ return { ...log, scopeLogs: filteredScopeLogs as any }
+ }).filter(Boolean) as LogEntry[]
+ }, [logs, selectedServices, excludedAttributes, showErrorsOnly, searchQuery, cutoffNano])
+
+ const unfocusedTraces = useMemo(() => {
+ return traces.map(trace => {
+ const filteredResourceSpans = (trace.resourceSpans?.map(rs => {
+ const serviceName = getServiceName(rs.resource?.attributes ?? [])
+ // Filter by service
+ if (selectedServices.size > 0 && !selectedServices.has(serviceName)) return null
+
+ const filteredScopeSpans = (rs.scopeSpans?.map(ss => {
+ const filteredSpans = ss.spans?.filter(span => {
+ // Filter by time window
+ if (cutoffNano > 0n) {
+ const tNano = BigInt(span.startTimeUnixNano || "0")
+ if (tNano > 0n && tNano < cutoffNano) return false
+ }
+
+ // Filter by latency threshold (minDurationMs)
+ if (minDurationMs > 0) {
+ const start = Number(span.startTimeUnixNano || "0")
+ const end = Number(span.endTimeUnixNano || span.startTimeUnixNano || "0")
+ const durMs = (end - start) / 1_000_000
+ if (durMs < minDurationMs) return false
+ }
+
+ // 1. Excluded attributes
+ const spanAttrs = Object.fromEntries(span.attributes?.map(a => [a.key, formatAttributeValue(a.value)]) ?? [])
+ const isExcluded = Object.entries(excludedAttributes).some(([key, values]) => {
+ const val = spanAttrs[key]
+ return val && values.has(val)
+ })
+ if (isExcluded) return false
+
+ // 2. Search query
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase()
+ const matchInName = (span.name || "").toLowerCase()?.includes(query) ?? false
+ const matchInTrace = (span.traceId || "").toLowerCase().includes(query)
+ const matchInAttrs = span.attributes?.some(a =>
+ a.key.toLowerCase()?.includes(query) ||
+ formatAttributeValue(a.value).toLowerCase()?.includes(query)
+ ) ?? false
+ if (!matchInName && !matchInAttrs && !matchInTrace) return false
+ }
+
+ return true
+ })
+ if (!filteredSpans || filteredSpans.length === 0) return null
+ return { ...ss, spans: filteredSpans }
+ }).filter(Boolean) as ScopeSpan[]) || []
+
+ if (!filteredScopeSpans || filteredScopeSpans.length === 0) return null
+ return { ...rs, scopeSpans: filteredScopeSpans }
+ }).filter(Boolean) as ResourceSpan[]) || []
+
+ if (!filteredResourceSpans || filteredResourceSpans.length === 0) return null
+
+ if (showErrorsOnly) {
+ const hasError = filteredResourceSpans?.some((rs: ResourceSpan) =>
+ rs?.scopeSpans?.some((ss: ScopeSpan) =>
+ ss?.spans?.some((span: Span) => isSpanError(span))
+ )
+ )
+ if (!hasError) return null
+ }
+
+ return { ...trace, resourceSpans: filteredResourceSpans }
+ }).filter(Boolean) as TraceData[]
+ }, [traces, selectedServices, excludedAttributes, showErrorsOnly, searchQuery, cutoffNano, minDurationMs])
+
+ // --- Selected Time Period Filtering (e.g. from waveform section click) ---
+ const filteredLogs = useMemo(() => {
+ if (!selectedTimeRange) return unfocusedLogs
+ const { startMs, endMs } = selectedTimeRange
+ return unfocusedLogs.map(log => {
+ const filteredScopeLogs = (log.scopeLogs ?? []).map(sl => {
+ const filteredRecords = (sl.logRecords ?? []).filter(record => {
+ const tMs = Number(BigInt(record.timeUnixNano || "0") / 1_000_000n)
+ return tMs >= startMs && tMs <= endMs
+ })
+ if (filteredRecords.length === 0) return null
+ return { ...sl, logRecords: filteredRecords }
+ }).filter(Boolean)
+
+ if (filteredScopeLogs.length === 0) return null
+ return { ...log, scopeLogs: filteredScopeLogs as any }
+ }).filter(Boolean) as LogEntry[]
+ }, [unfocusedLogs, selectedTimeRange])
+
+ const filteredTraces = useMemo(() => {
+ if (!selectedTimeRange) return unfocusedTraces
+ const { startMs, endMs } = selectedTimeRange
+ return unfocusedTraces.filter(trace => {
+ return (trace.resourceSpans ?? []).some(rs =>
+ (rs.scopeSpans ?? []).some(ss =>
+ (ss.spans ?? []).some(span => {
+ const spanStartMs = Number(BigInt(span.startTimeUnixNano || "0") / 1_000_000n)
+ const spanEndMs = Number(BigInt(span.endTimeUnixNano || span.startTimeUnixNano || "0") / 1_000_000n)
+ return spanStartMs <= endMs && spanEndMs >= startMs
+ })
+ )
+ )
+ })
+ }, [unfocusedTraces, selectedTimeRange])
+
+ const logCountBySpanId = useMemo(() => {
+ const counts: Record = {}
+ filteredLogs.forEach(log => {
+ ;(log.scopeLogs ?? []).forEach(sl => {
+ ;(sl.logRecords ?? []).forEach(record => {
+ if (record.spanId) {
+ counts[record.spanId] = (counts[record.spanId] || 0) + 1
+ }
+ })
+ })
+ })
+ return counts
+ }, [filteredLogs])
+
+ const filteredMetrics = useMemo(() => {
+ return metrics.map(data => {
+ const filteredResourceMetrics = data.resourceMetrics?.map(rm => {
+ const serviceName = rm.resource?.attributes?.find(attr => attr.key === "service.name")?.value.stringValue || "unknown-service"
+ // Filter by service
+ if (selectedServices.size > 0 && !selectedServices.has(serviceName)) return null
+
+ const filteredScopeMetrics = rm.scopeMetrics?.map(sm => {
+ const filteredMetricsList = sm.metrics?.filter(_m => {
+ return true
+ })
+ if (filteredMetricsList?.length === 0) return null
+ return { ...sm, metrics: filteredMetricsList }
+ }).filter(Boolean)
+
+ if (filteredScopeMetrics?.length === 0) return null
+ return { ...rm, scopeMetrics: filteredScopeMetrics }
+ }).filter(Boolean)
+
+ if (filteredResourceMetrics?.length === 0) return null
+ return { ...data, resourceMetrics: filteredResourceMetrics }
+ }).filter(Boolean) as MetricData[]
+ }, [metrics, selectedServices])
+
+ // --- Real Service Health & Offender Metrics ---
+ const serviceHealth = useMemo(() => {
+ const health: Record = {}
+
+ const getOrInit = (srv: string) => {
+ if (!health[srv]) {
+ health[srv] = {
+ name: srv,
+ totalSignals: 0,
+ errorCount: 0,
+ spansCount: 0,
+ logsCount: 0,
+ hasErrors: false,
+ }
+ }
+ return health[srv]
+ }
+
+ filteredLogs.forEach((log) => {
+ (log.scopeLogs ?? []).forEach((sl) => {
+ (sl.logRecords ?? []).forEach((r) => {
+ const sName = getServiceName(log.resource?.attributes ?? [], r.attributes ?? [])
+ if (sName && sName !== "unknown-service") {
+ const h = getOrInit(sName)
+ h.totalSignals++
+ h.logsCount++
+ if ((r.severityNumber ?? 0) >= 17) {
+ h.errorCount++
+ h.hasErrors = true
+ }
+ }
+ })
+ })
+ })
+
+ filteredTraces.forEach((trace) => {
+ trace.resourceSpans?.forEach((rs) => {
+ rs.scopeSpans?.forEach((ss) => {
+ ss.spans?.forEach((s) => {
+ const sName = getServiceName(rs.resource?.attributes ?? [], s.attributes ?? [])
+ if (sName && sName !== "unknown-service") {
+ const h = getOrInit(sName)
+ h.totalSignals++
+ h.spansCount++
+ if (isSpanError(s)) {
+ h.errorCount++
+ h.hasErrors = true
+ }
+ }
+ })
+ })
+ })
+ })
+
+ return health
+ }, [filteredLogs, filteredTraces])
+
+
+ useEffect(() => {
+ // Do not start protected history/WebSocket traffic until a session exists.
+ // The auth callback reloads the app after storing tokens, so this is also
+ // sufficient for the normal login flow.
+ if (!tokens()) return
+
+ const fetchHistory = async () => {
+ try {
+ // Fetch each signal type separately with its own generous limit,
+ // rather than one combined /api/history call - otherwise a noisy
+ // signal type (metrics in particular) can crowd the others out of
+ // the initial window.
+ const [tracesRes, logsRes, metricsRes] = await Promise.all([
+ authFetch("/api/history/traces?limit=300"),
+ authFetch("/api/history/logs?limit=300"),
+ authFetch("/api/history/metrics?limit=150"),
+ ])
+ const [tracesData, logsData, metricsData] = await Promise.all([
+ tracesRes.json() as Promise,
+ logsRes.json() as Promise,
+ metricsRes.json() as Promise,
+ ])
+
+ const partitioned = partitionMessages([
+ ...(tracesData ?? []),
+ ...(logsData ?? []),
+ ...(metricsData ?? []),
+ ])
+
+ console.log(
+ "Fetched historical data:",
+ partitioned.traces.length, "traces,",
+ partitioned.logs.length, "logs,",
+ partitioned.metrics.length, "metrics",
+ )
+
+ setTraces(partitioned.traces)
+ setMetrics(partitioned.metrics)
+ setLogs(partitioned.logs)
+ setLogsByTraceId(partitioned.logsByTraceId)
+ setKnownTraceIds(partitioned.traceIds)
+ } catch (error) {
+ console.error("Error fetching history:", error)
+ }
+ }
+
+ const connect = () => {
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
+ const t = tokens()
+ const tokenQuery = t?.accessToken ? `?access_token=${encodeURIComponent(t.accessToken)}` : ""
+ const wsUrl = `${protocol}//${window.location.host}/ws${tokenQuery}`
+ console.log(`Connecting to WebSocket at ${wsUrl}`)
+
+ const ws = new WebSocket(wsUrl)
+ wsRef.current = ws
+
+ ws.onopen = () => {
+ console.log("WebSocket connected")
+ setIsConnected(true)
+ }
+
+ ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data)
+
+ // Server issues notification broadcast
+ if (data?.type === "issues") {
+ setIssuesVersion((v) => v + 1)
+ return
+ }
+
+ // If stream is paused, buffer the incoming signal
+ if (!isStreamingRef.current) {
+ bufferedMessagesRef.current.push(data as OtelMessage)
+ setBufferedCount(bufferedMessagesRef.current.length)
+ return
+ }
+
+ const partitioned = partitionMessages([data as OtelMessage])
+
+ if (partitioned.traces.length) {
+ setTraces((prev) => [...prev, ...partitioned.traces])
+ }
+ if (partitioned.metrics.length) {
+ setMetrics((prev) => [...prev, ...partitioned.metrics])
+ }
+ if (partitioned.logs.length) {
+ setLogs((prev) => [...prev, ...partitioned.logs])
+ setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId))
+ }
+ if (partitioned.traceIds.size) {
+ setKnownTraceIds((prev) => {
+ const next = new Set(prev)
+ partitioned.traceIds.forEach((id) => next.add(id))
+ return next
+ })
+ }
+ } catch (error) {
+ console.error("Error parsing WebSocket message:", error)
+ }
+ }
+
+ ws.onerror = (error) => {
+ console.error("WebSocket error:", error)
+ setIsConnected(false)
+ }
+
+ ws.onclose = () => {
+ console.log("WebSocket disconnected, reconnecting in 5s...")
+ setIsConnected(false)
+
+ // Reconnect after 5 seconds
+ reconnectTimeoutRef.current = setTimeout(() => {
+ connect()
+ }, 5000)
+ }
+ }
+ fetchHistory().then(() => connect())
+
+ return () => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current)
+ }
+ if (wsRef.current) {
+ wsRef.current.close()
+ }
+ }
+ }, [flushBufferedStream])
+
+ // On-demand trace fetch: if the selected trace id isn't one we already
+ // have spans for (e.g. it arrived before this session connected, or fell
+ // outside the initial history window), fetch it directly instead of
+ // silently showing an empty trace view.
+ useEffect(() => {
+ if (!tokens() || !activeTraceId || knownTraceIds.has(activeTraceId)) return
+
+ let cancelled = false
+ authFetch(`/api/traces/${encodeURIComponent(activeTraceId)}`)
+ .then((res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ return res.json() as Promise
+ })
+ .then((data) => {
+ if (cancelled) return
+ const partitioned = partitionMessages(data ?? [])
+
+ if (partitioned.traces.length) {
+ setTraces((prev) => [...prev, ...partitioned.traces])
+ }
+ if (partitioned.metrics.length) {
+ setMetrics((prev) => [...prev, ...partitioned.metrics])
+ }
+ if (partitioned.logs.length) {
+ setLogs((prev) => [...prev, ...partitioned.logs])
+ setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId))
+ }
+
+ setKnownTraceIds((prev) => {
+ const next = new Set(prev)
+ partitioned.traceIds.forEach((id) => next.add(id))
+ next.add(activeTraceId)
+ return next
+ })
+ })
+ .catch((error) => {
+ console.error(`Error fetching trace ${activeTraceId}:`, error)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [activeTraceId, knownTraceIds])
+
+ return (
+
+ {children}
+
+ )
+
+}
diff --git a/otel-ui/src/contexts/ObservabilityContextValue.ts b/otel-ui/src/contexts/ObservabilityContextValue.ts
new file mode 100644
index 0000000..6a1bd1d
--- /dev/null
+++ b/otel-ui/src/contexts/ObservabilityContextValue.ts
@@ -0,0 +1,88 @@
+"use client";
+
+import { createContext } from "react";
+import type { LogEntry, MetricData, TraceData } from "../lib/types";
+
+export interface FilterState {
+ selectedServices: Set;
+ excludedAttributes: Record>;
+}
+
+export type TimeWindow = "15m" | "1h" | "6h" | "24h" | "all";
+
+export interface ServiceHealthSummary {
+ name: string;
+ totalSignals: number;
+ errorCount: number;
+ spansCount: number;
+ logsCount: number;
+ hasErrors: boolean;
+}
+
+export interface TimeRangeFilter {
+ startMs: number;
+ endMs: number;
+ label?: string;
+}
+
+interface ObservabilityContextType {
+ logs: LogEntry[];
+ metrics: MetricData[];
+ traces: TraceData[];
+ unfocusedLogs: LogEntry[];
+ unfocusedTraces: TraceData[];
+ logsByTraceId: Record;
+ logCountBySpanId: Record;
+ isConnected: boolean;
+
+ // Real Streaming / Live Tail Controls
+ isStreaming: boolean;
+ setIsStreaming: (streaming: boolean) => void;
+ bufferedCount: number;
+ flushBufferedStream: () => void;
+
+ // Real Time Window Filter
+ timeWindow: TimeWindow;
+ setTimeWindow: (window: TimeWindow) => void;
+
+ // Selected Time Period Filter (from waveform or custom selection)
+ selectedTimeRange: TimeRangeFilter | null;
+ setSelectedTimeRange: (range: TimeRangeFilter | null) => void;
+
+ // Latency / Anomaly Threshold Filter
+ minDurationMs: number;
+ setMinDurationMs: (ms: number) => void;
+
+ // Service Health & Facets
+ serviceHealth: Record;
+ issuesVersion: number;
+
+ // Global filters
+ selectedServices: Set;
+ toggleService: (service: string) => void;
+ excludedAttributes: Record>;
+ toggleExcludedValue: (key: string, value: string) => void;
+ removeExcludedValue: (key: string, value: string) => void;
+ clearAttributeKey: (key: string) => void;
+ resetFilters: () => void;
+ uniqueServices: string[];
+ showErrorsOnly: boolean;
+ setShowErrorsOnly: (show: boolean) => void;
+ searchQuery: string;
+ setSearchQuery: (query: string) => void;
+
+ // Active selection
+ activeTraceId: string | null;
+ setActiveTraceId: (id: string | null) => void;
+ activeSpanId: string | null;
+ setActiveSpanId: (id: string | null) => void;
+ activeTab: string;
+ setActiveTab: (tab: string) => void;
+ filtersExpanded: boolean;
+ setFiltersExpanded: (expanded: boolean) => void;
+}
+
+export const ObservabilityContext = createContext<
+ ObservabilityContextType | undefined
+>(undefined);
+
diff --git a/otel-ui/src/hooks/use-mobile.ts b/otel-ui/src/hooks/use-mobile.ts
new file mode 100644
index 0000000..4331d5c
--- /dev/null
+++ b/otel-ui/src/hooks/use-mobile.ts
@@ -0,0 +1,19 @@
+import * as React from 'react'
+
+const MOBILE_BREAKPOINT = 768
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(undefined)
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+ mql.addEventListener('change', onChange)
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ return () => mql.removeEventListener('change', onChange)
+ }, [])
+
+ return !!isMobile
+}
diff --git a/otel-ui/src/hooks/useIssues.ts b/otel-ui/src/hooks/useIssues.ts
new file mode 100644
index 0000000..86f515e
--- /dev/null
+++ b/otel-ui/src/hooks/useIssues.ts
@@ -0,0 +1,50 @@
+import { useCallback, useEffect } from "react"
+import useSWR from "swr"
+import type { Issue } from "../lib/types"
+import { authSWRFetcher, tokens } from "../lib/auth"
+import { useObservabilityData } from "./useObservabilityData"
+
+
+const FALLBACK_POLL_INTERVAL_MS = 30_000
+const LIVE_REFRESH_DEBOUNCE_MS = 1_000
+let lastLiveRefreshAt = 0
+
+// Polls the backend's deduplicated error view (GET /api/issues) rather than
+// deriving "errors" from the raw telemetry stream in the browser, so counts,
+// first/last-seen and dedup match what the server has actually persisted -
+// not just whatever fits in this tab's in-memory window. Updates arrive live
+// over the WebSocket (useLiveIssues); the interval is only a fallback for
+// when the socket is down.
+export function useIssues() {
+ const key = tokens() ? "/api/issues?limit=200" : null
+ const { data, error, isLoading, mutate } = useSWR(
+ key,
+ authSWRFetcher,
+ {
+ refreshInterval: FALLBACK_POLL_INTERVAL_MS,
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ isPaused: () => !tokens(),
+ },
+ )
+ const refresh = useCallback(() => mutate(), [mutate])
+
+ const { issuesVersion } = useObservabilityData()
+
+ // Live push over the primary WebSocket
+ useEffect(() => {
+ if (issuesVersion > 0 && tokens()) {
+ const now = Date.now()
+ if (now - lastLiveRefreshAt < LIVE_REFRESH_DEBOUNCE_MS) return
+ lastLiveRefreshAt = now
+ void refresh()
+ }
+ }, [issuesVersion, refresh])
+
+ return {
+ issues: data ?? [],
+ loading: isLoading,
+ error: error instanceof Error ? error.message : error ? String(error) : null,
+ refresh,
+ }
+}
diff --git a/otel-ui/src/hooks/useLiveIssues.ts b/otel-ui/src/hooks/useLiveIssues.ts
new file mode 100644
index 0000000..19a01fc
--- /dev/null
+++ b/otel-ui/src/hooks/useLiveIssues.ts
@@ -0,0 +1,84 @@
+// Live issue updates: the backend broadcasts "issues" events over the same
+// WebSocket as telemetry, so the Issues tab can refresh instantly when an
+// error is ingested instead of waiting for the next poll tick. Falls back to
+// polling if the socket misbehaves.
+import { useEffect, useState } from "react"
+
+export function useLiveIssues(onRefresh: () => void) {
+ const [live, setLive] = useState(false)
+
+ useEffect(() => {
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
+ let ws: WebSocket | null = null
+ let retryTimer: number | null = null
+ let pollTimer: number | null = null
+ let disposed = false
+
+ const startPolling = () => {
+ if (pollTimer === null) {
+ pollTimer = window.setInterval(onRefresh, 30_000)
+ }
+ }
+ const stopPolling = () => {
+ if (pollTimer !== null) {
+ window.clearInterval(pollTimer)
+ pollTimer = null
+ }
+ }
+
+ const connect = () => {
+ if (disposed) return
+ try {
+ ws = new WebSocket(`${protocol}//${window.location.host}/ws`)
+ } catch {
+ startPolling()
+ return
+ }
+
+ ws.onopen = () => {
+ setLive(true)
+ stopPolling()
+ }
+ ws.onmessage = (event) => {
+ try {
+ const msg = JSON.parse(event.data)
+ if (msg?.type === "issues") onRefresh()
+ } catch {
+ /* ignore malformed frames */
+ }
+ }
+ ws.onclose = () => {
+ setLive(false)
+ startPolling()
+ // Reuse the telemetry socket's cadence for reconnects.
+ retryTimer = window.setTimeout(connect, 5000)
+ }
+ ws.onerror = () => {
+ try {
+ ws?.close()
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+
+ connect()
+
+ return () => {
+ disposed = true
+ if (retryTimer !== null) window.clearTimeout(retryTimer)
+ stopPolling()
+ if (ws) {
+ ws.onclose = null
+ try {
+ ws.close()
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+ }, [onRefresh])
+
+ // Exposed so the Issues header can show a "live" vs "polling" indicator.
+ return live
+}
diff --git a/otel-ui/src/hooks/useLogsForTraceId.ts b/otel-ui/src/hooks/useLogsForTraceId.ts
new file mode 100644
index 0000000..f7e793c
--- /dev/null
+++ b/otel-ui/src/hooks/useLogsForTraceId.ts
@@ -0,0 +1,7 @@
+import { useMemo } from "react";
+import { useObservabilityData } from "./useObservabilityData";
+
+export const useLogsForTraceId = (traceId: string) => {
+ const { logsByTraceId } = useObservabilityData();
+ return useMemo(() => logsByTraceId[traceId] || [], [logsByTraceId, traceId]);
+};
\ No newline at end of file
diff --git a/otel-ui/src/hooks/useMetrics.ts b/otel-ui/src/hooks/useMetrics.ts
new file mode 100644
index 0000000..64a43a8
--- /dev/null
+++ b/otel-ui/src/hooks/useMetrics.ts
@@ -0,0 +1,129 @@
+import { useMemo } from "react";
+import { useObservabilityData } from "./useObservabilityData";
+import type { MetricData, MetricDataPoint } from "../lib/types";
+import { getPodName } from "../lib/otel-utils";
+
+export interface MetricDetails {
+ id: string;
+ name: string;
+ service: string;
+ type: "gauge" | "sum" | "histogram";
+ points: MetricDataPoint[];
+ unit?: string;
+ pods: string[];
+}
+
+export const useMetricsDetails = (): Record => {
+ const { metrics } = useObservabilityData();
+ return useMemo(() => {
+ const allMetrics: Record<
+ string,
+ {
+ id: string;
+ name: string;
+ service: string;
+ type: "gauge" | "sum" | "histogram";
+ points: MetricDataPoint[];
+ unit?: string;
+ podsSet: Set;
+ }
+ > = {};
+
+ metrics.forEach((metricData: MetricData) => {
+ (metricData.resourceMetrics ?? []).forEach((resourceMetric) => {
+ const resourceAttrs = resourceMetric.resource?.attributes ?? [];
+ const serviceName =
+ resourceAttrs.find((attr) => attr.key === "service.name")?.value.stringValue || "unknown-service";
+ const resourcePod = getPodName(resourceAttrs);
+
+ (resourceMetric.scopeMetrics ?? []).forEach((scopeMetric) => {
+ (scopeMetric.metrics ?? []).forEach((metric) => {
+ const key = `${serviceName}:${metric.name}`;
+ if (!allMetrics[key]) {
+ let detectedType: "gauge" | "sum" | "histogram" = "sum";
+ if (metric.gauge) detectedType = "gauge";
+ else if (metric.histogram) detectedType = "histogram";
+
+ allMetrics[key] = {
+ id: key,
+ name: metric.name,
+ service: serviceName,
+ type: detectedType,
+ points: [],
+ unit: (metric as any).unit || undefined,
+ podsSet: new Set(),
+ };
+ }
+
+ const enrichPoint = (p: MetricDataPoint): MetricDataPoint => {
+ const pod = getPodName(undefined, p.attributes) || resourcePod;
+ if (pod) {
+ allMetrics[key].podsSet.add(pod);
+ }
+ return {
+ ...p,
+ pod: pod || undefined,
+ };
+ };
+
+ if (metric.gauge) {
+ allMetrics[key].type = "gauge";
+ allMetrics[key].points.push(...(metric.gauge.dataPoints ?? []).map(enrichPoint));
+ }
+ if (metric.sum) {
+ if (allMetrics[key].type !== "gauge") {
+ allMetrics[key].type = "sum";
+ }
+ allMetrics[key].points.push(...(metric.sum.dataPoints ?? []).map(enrichPoint));
+ }
+ if (metric.histogram) {
+ if (allMetrics[key].type !== "gauge") {
+ allMetrics[key].type = "histogram";
+ }
+ const histogramPoints = (metric.histogram.dataPoints ?? []).map((point) =>
+ enrichPoint({
+ ...point,
+ attributes: point.attributes ?? [],
+ asDouble: point.sum ?? 0,
+ })
+ );
+ allMetrics[key].points.push(...histogramPoints);
+ }
+ });
+ });
+ });
+ });
+
+ const result: Record = {};
+ for (const key in allMetrics) {
+ const sorted = allMetrics[key].points.sort((a, b) => {
+ const aT = BigInt(a.timeUnixNano || "0");
+ const bT = BigInt(b.timeUnixNano || "0");
+ if (aT < bT) return -1;
+ if (aT > bT) return 1;
+ return 0;
+ });
+ result[key] = {
+ id: key,
+ name: allMetrics[key].name,
+ service: allMetrics[key].service,
+ type: allMetrics[key].type,
+ points: sorted.slice(-600),
+ unit: allMetrics[key].unit,
+ pods: Array.from(allMetrics[key].podsSet).sort(),
+ };
+ }
+ return result;
+ }, [metrics]);
+};
+
+export const useMetrics = () => {
+ const details = useMetricsDetails();
+ return useMemo(() => {
+ const grouped: Record = {};
+ for (const key in details) {
+ grouped[key] = details[key].points;
+ }
+ return grouped;
+ }, [details]);
+};
diff --git a/otel-ui/src/hooks/useObservabilityData.ts b/otel-ui/src/hooks/useObservabilityData.ts
new file mode 100644
index 0000000..b5a081b
--- /dev/null
+++ b/otel-ui/src/hooks/useObservabilityData.ts
@@ -0,0 +1,14 @@
+"use client";
+
+import { useContext } from "react";
+import { ObservabilityContext } from "../contexts/ObservabilityContextValue";
+
+export function useObservabilityData() {
+ const context = useContext(ObservabilityContext);
+ if (!context) {
+ throw new Error(
+ "useObservabilityData must be used within an ObservabilityProvider",
+ );
+ }
+ return context;
+}
diff --git a/otel-ui/src/hooks/usePatterns.ts b/otel-ui/src/hooks/usePatterns.ts
new file mode 100644
index 0000000..723c367
--- /dev/null
+++ b/otel-ui/src/hooks/usePatterns.ts
@@ -0,0 +1,41 @@
+import useSWR from "swr"
+import { authSWRFetcher, authFetch, tokens } from "../lib/auth"
+import type { Issue, IssuePattern, SimilarIssue } from "../lib/types"
+
+export function usePatterns() {
+ const enabled = Boolean(tokens())
+ const patterns = useSWR(enabled ? "/api/patterns" : null, authSWRFetcher, {
+ revalidateOnFocus: false,
+ refreshInterval: 30_000,
+ })
+ const issues = useSWR(enabled ? "/api/issues?limit=200" : null, authSWRFetcher, {
+ revalidateOnFocus: false,
+ refreshInterval: 30_000,
+ })
+
+ const similar = async (id: string) => {
+ const response = await authFetch(`/api/issues/${encodeURIComponent(id)}/similar?limit=8`)
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
+ return (await response.json()) as SimilarIssue[]
+ }
+
+ const review = async (id: string, otherId: string, decision: "same" | "different") => {
+ const response = await authFetch(`/api/issues/${encodeURIComponent(id)}/similar`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ issueId: otherId, decision }),
+ })
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
+ await Promise.all([patterns.mutate(), issues.mutate()])
+ }
+
+ return {
+ patterns: patterns.data ?? [],
+ issues: issues.data ?? [],
+ loading: patterns.isLoading || issues.isLoading,
+ error: patterns.error ?? issues.error,
+ refresh: () => Promise.all([patterns.mutate(), issues.mutate()]),
+ similar,
+ review,
+ }
+}
diff --git a/otel-ui/src/hooks/useRollouts.ts b/otel-ui/src/hooks/useRollouts.ts
new file mode 100644
index 0000000..f7abc95
--- /dev/null
+++ b/otel-ui/src/hooks/useRollouts.ts
@@ -0,0 +1,14 @@
+import useSWR from "swr"
+import { authSWRFetcher, authFetch, tokens } from "../lib/auth"
+import type { Rollout, RolloutImpact } from "../lib/types"
+
+export function useRollouts() {
+ const key = tokens() ? "/api/rollouts" : null
+ const state = useSWR(key, authSWRFetcher, { refreshInterval: 30_000, revalidateOnFocus: false, shouldRetryOnError: false })
+ const impact = async (id: string) => {
+ const response = await authFetch(`/api/rollouts/${encodeURIComponent(id)}/impact`)
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
+ return (await response.json()) as RolloutImpact
+ }
+ return { rollouts: state.data ?? [], loading: state.isLoading, error: state.error, refresh: state.mutate, impact }
+}
diff --git a/otel-ui/src/hooks/useStats.ts b/otel-ui/src/hooks/useStats.ts
new file mode 100644
index 0000000..5078393
--- /dev/null
+++ b/otel-ui/src/hooks/useStats.ts
@@ -0,0 +1,60 @@
+import { useCallback } from "react"
+import useSWR from "swr"
+import type { ErrorStatsResponse, ReleaseStatsResponse } from "../lib/types"
+import { authFetch, authSWRFetcher, tokens } from "../lib/auth"
+
+/**
+ * True when the backend has ClickHouse-backed stats (the endpoints answer
+ * 200 instead of the documented 501). null while checking, so the Trends
+ * tab can hide itself entirely on file-store deployments.
+ */
+export function useStatsAvailability() {
+ const { data } = useSWR(tokens() ? "/api/stats/services" : null, async (key) => {
+ const res = await authFetch(key)
+ return res.ok
+ }, { revalidateOnFocus: false, shouldRetryOnError: false, isPaused: () => !tokens() })
+ return data ?? null
+}
+
+export interface StatsRange {
+ /** Lookback window in hours; server caps at 2160 (90 days). */
+ hours: number
+ service: string
+}
+
+const DEFAULT_RANGE: StatsRange = { hours: 72, service: "" }
+
+/**
+ * Fetches both error-trend and release-comparison series for the selected
+ * range. Both endpoints come from the same ClickHouse rollup, so a single
+ * refresh covers the tab.
+ */
+export function useStats(range: StatsRange = DEFAULT_RANGE) {
+ const params = new URLSearchParams({ hours: String(range.hours) })
+ if (range.service) params.set("service", range.service)
+ const key = tokens() ? `/api/stats/errors?${params}` : null
+ const { data, error, isLoading, mutate } = useSWR<{
+ errors: ErrorStatsResponse
+ releases: ReleaseStatsResponse | null
+ services: string[]
+ }>(key, async () => {
+ const [errors, releases, services] = await Promise.all([
+ authSWRFetcher(`/api/stats/errors?${params}`),
+ authSWRFetcher(`/api/stats/releases?${params}`).catch(() => null),
+ authSWRFetcher<{ services?: string[] }>("/api/stats/services").catch(() => null),
+ ])
+ return { errors, releases, services: services?.services ?? [] }
+ }, { revalidateOnFocus: false, shouldRetryOnError: false, isPaused: () => !tokens() })
+
+ const refresh = useCallback(() => mutate(), [mutate])
+
+ return {
+ errorPoints: data?.errors.points ?? [],
+ granularity: data?.errors.granularity ?? "hour",
+ releaseSeries: data?.releases?.series ?? {},
+ services: data?.services ?? [],
+ loading: isLoading,
+ error: error instanceof Error ? error.message : error ? String(error) : null,
+ refresh,
+ }
+}
diff --git a/otel-ui/src/index.css b/otel-ui/src/index.css
new file mode 100644
index 0000000..559d937
--- /dev/null
+++ b/otel-ui/src/index.css
@@ -0,0 +1,90 @@
+@import "tailwindcss";
+
+@theme {
+ --color-background: #0f131c;
+ --color-surface: #0f131c;
+ --color-surface-dim: #0f131c;
+ --color-surface-bright: #353942;
+ --color-surface-container-lowest: #0a0e16;
+ --color-surface-container-low: #181c24;
+ --color-surface-container: #1c2028;
+ --color-surface-container-high: #262a33;
+ --color-surface-container-highest: #31353e;
+ --color-surface-variant: #31353e;
+ --color-surface-tint: #00dbe9;
+
+ --color-on-background: #dfe2ee;
+ --color-on-surface: #dfe2ee;
+ --color-on-surface-variant: #b9cacb;
+ --color-inverse-surface: #dfe2ee;
+ --color-inverse-on-surface: #2c3039;
+
+ --color-primary: #dbfcff;
+ --color-primary-container: #00f0ff;
+ --color-on-primary: #00363a;
+ --color-on-primary-container: #006970;
+ --color-primary-fixed: #7df4ff;
+ --color-primary-fixed-dim: #00dbe9;
+ --color-on-primary-fixed: #002022;
+ --color-on-primary-fixed-variant: #004f54;
+ --color-inverse-primary: #006970;
+
+ --color-secondary: #4edea3;
+ --color-secondary-container: #00a572;
+ --color-on-secondary: #003824;
+ --color-on-secondary-container: #00311f;
+ --color-secondary-fixed: #6ffbbe;
+ --color-secondary-fixed-dim: #4edea3;
+ --color-on-secondary-fixed: #002113;
+ --color-on-secondary-fixed-variant: #005236;
+
+ --color-tertiary: #fff3f3;
+ --color-tertiary-container: #ffcdd1;
+ --color-on-tertiary: #670020;
+ --color-on-tertiary-container: #be0042;
+ --color-tertiary-fixed: #ffd9dc;
+ --color-tertiary-fixed-dim: #ffb2ba;
+ --color-on-tertiary-fixed: #400011;
+ --color-on-tertiary-fixed-variant: #910030;
+
+ --color-error: #ffb4ab;
+ --color-error-container: #93000a;
+ --color-on-error: #690005;
+ --color-on-error-container: #ffdad6;
+
+ --color-outline: #849495;
+ --color-outline-variant: #3b494b;
+
+ --font-headline: "Geist", sans-serif;
+ --font-body: "Inter", sans-serif;
+ --font-mono: "JetBrains Mono", monospace;
+}
+
+@layer base {
+ html, body {
+ margin: 0;
+ padding: 0;
+ background-color: #0f131c;
+ color: #dfe2ee;
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
+ }
+ body {
+ overscroll-behavior: none;
+ }
+}
+
+.material-symbols-outlined {
+ font-family: 'Material Symbols Outlined';
+ font-weight: normal;
+ font-style: normal;
+ font-size: 20px;
+ line-height: 1;
+ letter-spacing: normal;
+ text-transform: none;
+ display: inline-block;
+ white-space: nowrap;
+ word-wrap: normal;
+ direction: ltr;
+ -webkit-font-feature-settings: 'liga';
+ -webkit-font-smoothing: antialiased;
+}
diff --git a/otel-ui/src/lib/auth.ts b/otel-ui/src/lib/auth.ts
new file mode 100644
index 0000000..81770d6
--- /dev/null
+++ b/otel-ui/src/lib/auth.ts
@@ -0,0 +1,240 @@
+// OIDC authorization-code flow with PKCE against the go-auth service.
+// Client is public (no secret); tokens live in localStorage.
+
+const ISSUER =
+ (import.meta.env.VITE_OIDC_ISSUER as string | undefined) ??
+ "https://users.k6n.net";
+const CLIENT_ID =
+ (import.meta.env.VITE_OIDC_CLIENT_ID as string | undefined) ?? "otel-debug";
+const SCOPE = "openid profile email offline_access";
+
+const TOKEN_KEY = "otel.auth.tokens";
+
+export interface Tokens {
+ accessToken: string;
+ idToken: string;
+ refreshToken?: string;
+ expiresAt: number; // epoch ms
+}
+
+export interface UserClaims {
+ sub: string;
+ name?: string;
+ email?: string;
+ roles?: string[];
+}
+
+export class AuthRequiredError extends Error {
+ constructor() {
+ super("Authentication required")
+ this.name = "AuthRequiredError"
+ }
+}
+
+function b64url(bytes: Uint8Array): string {
+ return btoa(String.fromCharCode(...bytes))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+}
+
+function readTokens(): Tokens | null {
+ if (typeof window === "undefined") return null;
+ const raw = localStorage.getItem(TOKEN_KEY);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as Tokens;
+ } catch {
+ return null;
+ }
+}
+
+// tokens() returns tokens if the access token is currently valid.
+export function tokens(): Tokens | null {
+ const t = readTokens();
+ return t && t.expiresAt > Date.now() + 10_000 ? t : null;
+}
+
+export function hasSession(): boolean {
+ return readTokens() !== null;
+}
+
+let refreshing: Promise | null = null;
+
+// ensureTokens() returns valid tokens, refreshing if close to expiration.
+export async function ensureTokens(): Promise {
+ const t = readTokens();
+ if (!t) return null;
+ if (t.expiresAt > Date.now() + 30_000) return t;
+ if (!t.refreshToken) return null;
+
+ refreshing ??= doRefresh(t).finally(() => {
+ refreshing = null;
+ });
+ return refreshing;
+}
+
+async function doRefresh(t: Tokens): Promise {
+ try {
+ const res = await fetch("/api/auth/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ client_id: CLIENT_ID,
+ refresh_token: t.refreshToken!,
+ }),
+ });
+ if (!res.ok) {
+ localStorage.removeItem(TOKEN_KEY);
+ return null;
+ }
+ const body = (await res.json()) as TokenResponse;
+ saveTokens(body, t.refreshToken);
+ return readTokens();
+ } catch {
+ return null;
+ }
+}
+
+interface TokenResponse {
+ access_token: string;
+ id_token: string;
+ refresh_token?: string;
+ expires_in: number;
+}
+
+function saveTokens(body: TokenResponse, fallbackRefresh?: string) {
+ if (typeof window === "undefined") return;
+ localStorage.setItem(
+ TOKEN_KEY,
+ JSON.stringify({
+ accessToken: body.access_token,
+ idToken: body.id_token,
+ refreshToken: body.refresh_token ?? fallbackRefresh,
+ expiresAt: Date.now() + body.expires_in * 1000,
+ } satisfies Tokens),
+ );
+}
+
+export function currentUser(): UserClaims | null {
+ const t = tokens();
+ if (!t || !t.idToken) return null;
+ try {
+ const payload = t.idToken.split(".")[1];
+ return JSON.parse(
+ atob(payload.replace(/-/g, "+").replace(/_/g, "/")),
+ ) as UserClaims;
+ } catch {
+ return null;
+ }
+}
+
+export function logout() {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(TOKEN_KEY);
+ const end = `${ISSUER}/oidc/v1/end_session`;
+ window.location.assign(
+ `${end}?post_logout_redirect_uri=${encodeURIComponent(window.location.origin)}`,
+ );
+}
+
+function redirectUri(): string {
+ return `${window.location.origin}/auth_callback`;
+}
+
+export async function login() {
+ if (typeof window === "undefined") return;
+ const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
+ const verifier = b64url(verifierBytes);
+ const challenge = b64url(
+ new Uint8Array(
+ await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(verifier),
+ ),
+ ),
+ );
+ const state = b64url(crypto.getRandomValues(new Uint8Array(16)));
+
+ sessionStorage.setItem(
+ "otel.auth.pkce",
+ JSON.stringify({
+ verifier,
+ state,
+ back: window.location.pathname + window.location.search,
+ }),
+ );
+
+ const params = new URLSearchParams({
+ client_id: CLIENT_ID,
+ redirect_uri: redirectUri(),
+ response_type: "code",
+ scope: SCOPE,
+ code_challenge: challenge,
+ code_challenge_method: "S256",
+ state,
+ });
+
+ window.location.assign(`${ISSUER}/oauth/v2/authorize?${params}`);
+}
+
+export async function handleCallback(): Promise {
+ if (typeof window === "undefined") return null;
+ const url = new URL(window.location.href);
+ const code = url.searchParams.get("code");
+ const state = url.searchParams.get("state");
+ if (!code) return null;
+
+ const saved = JSON.parse(
+ sessionStorage.getItem("otel.auth.pkce") ?? "null",
+ ) as { verifier: string; state: string; back: string } | null;
+ sessionStorage.removeItem("otel.auth.pkce");
+
+ if (!saved || state !== saved.state) {
+ throw new Error("OIDC state mismatch");
+ }
+
+ const res = await fetch("/api/auth/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ client_id: CLIENT_ID,
+ redirect_uri: redirectUri(),
+ code,
+ code_verifier: saved.verifier,
+ }),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Token exchange failed with status ${res.status}`);
+ }
+
+ saveTokens((await res.json()) as TokenResponse);
+ const destination = saved.back || "/";
+ window.history.replaceState(null, "", destination);
+ return destination;
+}
+
+export async function authFetch(
+ input: RequestInfo | URL,
+ init?: RequestInit,
+): Promise {
+ const t = await ensureTokens();
+ // Protected API callers must never send anonymous requests. Apart from
+ // avoiding unnecessary 401s, this prevents the RUM error reporter from
+ // turning an expired session into an issues-refresh loop.
+ if (!t?.accessToken) throw new AuthRequiredError()
+ const headers = new Headers(init?.headers);
+ if (t?.accessToken && !headers.has("Authorization")) {
+ headers.set("Authorization", `Bearer ${t.accessToken}`);
+ }
+ return fetch(input, { ...init, headers });
+}
+
+export async function authSWRFetcher(key: string): Promise {
+ const response = await authFetch(key)
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
+ return response.json() as Promise
+}
diff --git a/otel-ui/src/lib/ingest.ts b/otel-ui/src/lib/ingest.ts
new file mode 100644
index 0000000..6d3a2d2
--- /dev/null
+++ b/otel-ui/src/lib/ingest.ts
@@ -0,0 +1,85 @@
+// Shared message-partitioning logic used by every ingestion path: the
+// initial history fetch, live WebSocket messages, and on-demand trace
+// fetches (GET /api/traces/{traceId}). Keeping this in one place means all
+// three stay consistent instead of drifting.
+import type { LogEntry, MetricData, OtelMessage, TraceData } from "./types"
+import { normalizeLogEntry, normalizeMetricData, normalizeTrace } from "./normalize"
+
+export interface PartitionedMessages {
+ traces: TraceData[]
+ metrics: MetricData[]
+ logs: LogEntry[]
+ logsByTraceId: Record
+ traceIds: Set
+}
+
+export function partitionMessages(messages: OtelMessage[]): PartitionedMessages {
+ const traces: TraceData[] = []
+ const metrics: MetricData[] = []
+ const logs: LogEntry[] = []
+ const logsByTraceId: Record = {}
+ const traceIds = new Set()
+
+ messages.forEach((message) => {
+ switch (message.type) {
+ case "traces": {
+ const normalized = normalizeTrace(message.data)
+ traces.push(normalized)
+ normalized.resourceSpans.forEach((rs) =>
+ rs.scopeSpans.forEach((ss) =>
+ ss.spans.forEach((span) => {
+ if (span.traceId) traceIds.add(span.traceId)
+ }),
+ ),
+ )
+ break
+ }
+ case "metrics":
+ metrics.push(normalizeMetricData(message.data))
+ break
+ case "logs": {
+ const logsWithId = (message.data.resourceLogs ?? []).map((log) =>
+ normalizeLogEntry({
+ ...log,
+ id: crypto.randomUUID(),
+ scopeLogs: (log.scopeLogs ?? []).map((sl) => ({
+ ...sl,
+ logRecords: (sl.logRecords ?? []).map((record) => ({
+ ...record,
+ uid: record.uid ?? crypto.randomUUID(),
+ })),
+ })),
+ }),
+ )
+ logsWithId.forEach((log) => {
+ logs.push(log)
+ log.scopeLogs.forEach((scopeLog) => {
+ scopeLog.logRecords.forEach((record) => {
+ if (record.traceId) {
+ if (!logsByTraceId[record.traceId]) {
+ logsByTraceId[record.traceId] = []
+ }
+ logsByTraceId[record.traceId].push(log)
+ }
+ })
+ })
+ })
+ break
+ }
+ }
+ })
+
+ return { traces, metrics, logs, logsByTraceId, traceIds }
+}
+
+export function mergeLogsByTraceId(
+ prev: Record,
+ additions: Record,
+): Record {
+ if (Object.keys(additions).length === 0) return prev
+ const next = { ...prev }
+ Object.entries(additions).forEach(([traceId, entries]) => {
+ next[traceId] = [...(next[traceId] ?? []), ...entries]
+ })
+ return next
+}
diff --git a/otel-ui/src/lib/issue-utils.ts b/otel-ui/src/lib/issue-utils.ts
new file mode 100644
index 0000000..aed225f
--- /dev/null
+++ b/otel-ui/src/lib/issue-utils.ts
@@ -0,0 +1,95 @@
+// Helpers for presenting backend issues in the UI: trend sparkline data,
+// breadcrumb parsing and triage-state presentation.
+import type { Issue, IssueSample } from "./types"
+
+export interface TrendPoint {
+ hour: string
+ count: number
+}
+
+/**
+ * Converts an issue's sparse hourly trend map into a dense series covering
+ * the full look-back window, oldest first - ready for a sparkline.
+ */
+export function trendPoints(issue: Issue, hours = 24): TrendPoint[] {
+ const now = new Date()
+ const currentHour = new Date(
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours()),
+ )
+ const points: TrendPoint[] = []
+ for (let i = hours - 1; i >= 0; i--) {
+ const d = new Date(currentHour.getTime() - i * 3_600_000)
+ const hour = d.toISOString().slice(0, 13) + ":00"
+ points.push({ hour, count: issue.trend?.[hour] ?? 0 })
+ }
+ return points
+}
+
+/** True when the issue has at least one occurrence in the last hour. */
+export function isSpike(issue: Issue): boolean {
+ const points = trendPoints(issue, 1)
+ return (points[0]?.count ?? 0) > 1
+}
+
+export interface ParsedBreadcrumb {
+ t: number
+ category: string
+ message: string
+}
+
+/** Safely parses the raw breadcrumb JSON attached to a frontend sample. */
+export function parseBreadcrumbs(raw?: string): ParsedBreadcrumb[] {
+ if (!raw) return []
+ try {
+ const parsed = JSON.parse(raw)
+ if (!Array.isArray(parsed)) return []
+ return parsed.filter(
+ (b): b is ParsedBreadcrumb =>
+ b && typeof b === "object" && typeof b.category === "string" && typeof b.message === "string",
+ )
+ } catch {
+ return []
+ }
+}
+
+export type IssueStatus = "open" | "resolved" | "ignored"
+
+export const issueStatusMeta: Record = {
+ open: { label: "Open", className: "bg-rose-500/10 text-rose-300 border-rose-500/20" },
+ resolved: {
+ label: "Resolved",
+ className: "bg-emerald-500/10 text-emerald-300 border-emerald-500/20",
+ },
+ ignored: { label: "Ignored", className: "bg-slate-500/10 text-slate-400 border-white/10" },
+}
+
+export function issueStatusOf(issue: Issue): IssueStatus {
+ if (issue.status === "resolved" || issue.status === "ignored") return issue.status
+ return "open"
+}
+
+/** Most informative stacktrace across an issue's samples, if any. */
+export function latestStacktrace(issue: Issue): string | undefined {
+ for (let i = issue.samples.length - 1; i >= 0; i--) {
+ if (issue.samples[i]?.resolvedStacktrace) return issue.samples[i].resolvedStacktrace
+ if (issue.samples[i]?.stacktrace) return issue.samples[i].stacktrace
+ }
+ return undefined
+}
+
+/** Latest sample carrying breadcrumbs (frontend issues), if any. */
+export function latestBreadcrumbs(issue: Issue): ParsedBreadcrumb[] {
+ for (let i = issue.samples.length - 1; i >= 0; i--) {
+ const parsed = parseBreadcrumbs(issue.samples[i]?.breadcrumbs)
+ if (parsed.length) return parsed
+ }
+ return []
+}
+
+export function sampleContext(sample: IssueSample): string[] {
+ const parts: string[] = []
+ if (sample.httpMethod) parts.push(sample.httpMethod)
+ if (sample.httpRoute) parts.push(sample.httpRoute)
+ if (sample.httpStatus) parts.push(String(sample.httpStatus))
+ return parts
+}
diff --git a/otel-ui/src/lib/normalize.ts b/otel-ui/src/lib/normalize.ts
new file mode 100644
index 0000000..9189735
--- /dev/null
+++ b/otel-ui/src/lib/normalize.ts
@@ -0,0 +1,74 @@
+// OTLP JSON exporters omit fields when empty, so any of the arrays below can
+// be undefined at runtime even though the types say otherwise. Normalize every
+// message once at ingestion so consumers can rely on the declared types.
+import type { LogEntry, LogRecord, MetricData, TraceData } from "./types"
+
+const attrList = (value: unknown): LogRecord["attributes"] =>
+ Array.isArray(value) ? (value as LogRecord["attributes"]) : []
+
+const normalizeLogRecord = (record: any): LogRecord => ({
+ ...record,
+ attributes: attrList(record?.attributes),
+})
+
+export const normalizeLogEntry = (log: any): LogEntry => ({
+ ...log,
+ id: log?.id ?? crypto.randomUUID(),
+ resource: {
+ ...log?.resource,
+ attributes: attrList(log?.resource?.attributes),
+ },
+ scopeLogs: attrList(log?.scopeLogs).map((scopeLog: any) => ({
+ ...scopeLog,
+ scope: scopeLog?.scope ?? { name: "unknown" },
+ logRecords: attrList(scopeLog?.logRecords).map(normalizeLogRecord),
+ })),
+})
+
+export const normalizeTrace = (trace: any): TraceData => ({
+ ...trace,
+ resourceSpans: attrList(trace?.resourceSpans).map((resourceSpan: any) => ({
+ ...resourceSpan,
+ resource: {
+ ...resourceSpan?.resource,
+ attributes: attrList(resourceSpan?.resource?.attributes),
+ },
+ scopeSpans: attrList(resourceSpan?.scopeSpans).map((scopeSpan: any) => ({
+ ...scopeSpan,
+ scope: scopeSpan?.scope ?? { name: "unknown" },
+ spans: attrList(scopeSpan?.spans).map((span: any) => ({
+ ...span,
+ attributes: attrList(span?.attributes),
+ events: attrList(span?.events),
+ status: span?.status ?? {},
+ })),
+ })),
+ })),
+})
+
+export const normalizeMetricData = (data: any): MetricData => ({
+ ...data,
+ resourceMetrics: attrList(data?.resourceMetrics).map((resourceMetric: any) => ({
+ ...resourceMetric,
+ resource: {
+ ...resourceMetric?.resource,
+ attributes: attrList(resourceMetric?.resource?.attributes),
+ },
+ scopeMetrics: attrList(resourceMetric?.scopeMetrics).map((scopeMetric: any) => ({
+ ...scopeMetric,
+ scope: scopeMetric?.scope ?? { name: "unknown" },
+ metrics: attrList(scopeMetric?.metrics).map((metric: any) => ({
+ ...metric,
+ gauge: metric?.gauge
+ ? { ...metric.gauge, dataPoints: attrList(metric.gauge.dataPoints) }
+ : undefined,
+ sum: metric?.sum
+ ? { ...metric.sum, dataPoints: attrList(metric.sum.dataPoints) }
+ : undefined,
+ histogram: metric?.histogram
+ ? { ...metric.histogram, dataPoints: attrList(metric.histogram.dataPoints) }
+ : undefined,
+ })),
+ })),
+ })),
+})
diff --git a/otel-ui/src/lib/otel-utils.ts b/otel-ui/src/lib/otel-utils.ts
new file mode 100644
index 0000000..fb1e1e5
--- /dev/null
+++ b/otel-ui/src/lib/otel-utils.ts
@@ -0,0 +1,209 @@
+import type { AttributeValue, OtelAttribute } from "./types";
+
+export function getAttributeValue(
+ attributes: OtelAttribute[],
+ key: string,
+): string {
+ const lowerKey = key.toLowerCase();
+ const attr = attributes.find((a) => a.key === key || a.key.toLowerCase() === lowerKey);
+ if (!attr) return "";
+
+ const { value } = attr;
+ if (value.stringValue) return value.stringValue;
+ if (value.intValue) return value.intValue;
+ if (value.doubleValue !== undefined) return value.doubleValue.toString();
+ if (value.boolValue !== undefined) return value.boolValue.toString();
+
+ return "";
+}
+
+export function formatTimestamp(nanoTimestamp: string): string {
+ const ms = Number(nanoTimestamp) / 1_000_000;
+ return new Date(ms).toLocaleString();
+}
+
+export function formatDuration(startNano: string, endNano: string): string {
+ const durationMs = (Number(endNano) - Number(startNano)) / 1_000_000;
+
+ if (durationMs < 1) {
+ return `${(durationMs * 1000).toFixed(2)}μs`;
+ } else if (durationMs < 1000) {
+ return `${durationMs.toFixed(2)}ms`;
+ } else {
+ return `${(durationMs / 1000).toFixed(2)}s`;
+ }
+}
+
+export function getSpanKindLabel(kind: number): string {
+ const kinds = [
+ "UNSPECIFIED",
+ "INTERNAL",
+ "SERVER",
+ "CLIENT",
+ "PRODUCER",
+ "CONSUMER",
+ ];
+ return kinds[kind] || "UNKNOWN";
+}
+
+export function getServiceName(
+ resourceAttributes?: OtelAttribute[],
+ recordAttributes?: OtelAttribute[],
+): string {
+ const keys = ["service.name", "app.kubernetes.io/name", "app", "name"];
+ if (resourceAttributes) {
+ for (const k of keys) {
+ const v = getAttributeValue(resourceAttributes, k);
+ if (v && v !== "unknown" && v !== "unknown-service") return v;
+ }
+ }
+ if (recordAttributes) {
+ for (const k of keys) {
+ const v = getAttributeValue(recordAttributes, k);
+ if (v && v !== "unknown" && v !== "unknown-service") return v;
+ }
+ }
+ return "unknown-service";
+}
+
+export function isSpanError(span: {
+ status?: { code?: number };
+ attributes?: OtelAttribute[];
+}): boolean {
+ if (span.status?.code === 2) return true;
+ const statusStr =
+ getAttributeValue(span.attributes ?? [], "http.response.status_code") ||
+ getAttributeValue(span.attributes ?? [], "http.status_code");
+ if (statusStr) {
+ const code = parseInt(statusStr, 10);
+ if (!isNaN(code) && code >= 400) return true;
+ }
+ const grpcStr = getAttributeValue(span.attributes ?? [], "rpc.grpc.status_code");
+ if (grpcStr) {
+ const code = parseInt(grpcStr, 10);
+ if (!isNaN(code) && code > 0) return true;
+ }
+ return false;
+}
+
+export function getPodName(
+ resourceAttributes?: OtelAttribute[],
+ pointAttributes?: OtelAttribute[],
+): string {
+ const keys = [
+ "k8s.pod.name",
+ "pod.name",
+ "pod",
+ "pod_name",
+ "k8s_pod_name",
+ "service.instance.id",
+ "service_instance_id",
+ "instance.id",
+ "k8s.pod.uid",
+ "container.name",
+ "container_name",
+ "container.id",
+ "host.name",
+ "hostname",
+ "host",
+ "instance",
+ "process.pid",
+ ];
+ if (resourceAttributes) {
+ for (const k of keys) {
+ const v = getAttributeValue(resourceAttributes, k);
+ if (v) return v;
+ }
+ }
+ if (pointAttributes) {
+ for (const k of keys) {
+ const v = getAttributeValue(pointAttributes, k);
+ if (v) return v;
+ }
+ }
+ return "";
+}
+
+export const POD_COLORS = [
+ { stroke: "#38bdf8", fill: "#38bdf8", text: "text-sky-400", bg: "bg-sky-500/10", border: "border-sky-500/20" },
+ { stroke: "#a855f7", fill: "#a855f7", text: "text-purple-400", bg: "bg-purple-500/10", border: "border-purple-500/20" },
+ { stroke: "#34d399", fill: "#34d399", text: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
+ { stroke: "#fbbf24", fill: "#fbbf24", text: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
+ { stroke: "#fb7185", fill: "#fb7185", text: "text-rose-400", bg: "bg-rose-500/10", border: "border-rose-500/20" },
+ { stroke: "#22d3ee", fill: "#22d3ee", text: "text-cyan-400", bg: "bg-cyan-500/10", border: "border-cyan-500/20" },
+ { stroke: "#f472b6", fill: "#f472b6", text: "text-pink-400", bg: "bg-pink-500/10", border: "border-pink-500/20" },
+ { stroke: "#818cf8", fill: "#818cf8", text: "text-indigo-400", bg: "bg-indigo-500/10", border: "border-indigo-500/20" },
+];
+
+export function getPodPalette(pod: string) {
+ return POD_COLORS[hashString(pod) % POD_COLORS.length] ?? POD_COLORS[0];
+}
+
+export const formatAttributeValue = (value: AttributeValue): string => {
+ if (value.stringValue !== undefined) return value.stringValue;
+ if (value.intValue !== undefined) return value.intValue.toString();
+ if (value.doubleValue !== undefined) return value.doubleValue.toString();
+ if (value.boolValue !== undefined) return value.boolValue.toString();
+ return "unknown";
+};
+
+export const servicePalettes = [
+ {
+ gradient: "from-violet-600 via-indigo-600 to-sky-600",
+ ring: "ring-violet-400/40",
+ text: "text-violet-400",
+ bg: "bg-violet-400/10",
+ border: "border-violet-400/20",
+ },
+ {
+ gradient: "from-emerald-500 via-teal-600 to-cyan-700",
+ ring: "ring-emerald-400/40",
+ text: "text-emerald-400",
+ bg: "bg-emerald-400/10",
+ border: "border-emerald-400/20",
+ },
+ {
+ gradient: "from-amber-600 via-orange-600 to-rose-600",
+ ring: "ring-amber-400/40",
+ text: "text-amber-400",
+ bg: "bg-amber-400/10",
+ border: "border-amber-400/20",
+ },
+ {
+ gradient: "from-fuchsia-600 via-purple-600 to-indigo-700",
+ ring: "ring-purple-400/40",
+ text: "text-fuchsia-400",
+ bg: "bg-fuchsia-400/10",
+ border: "border-fuchsia-400/20",
+ },
+ {
+ gradient: "from-sky-500 via-blue-600 to-indigo-700",
+ ring: "ring-sky-400/40",
+ text: "text-sky-400",
+ bg: "bg-sky-400/10",
+ border: "border-sky-400/20",
+ },
+ {
+ gradient: "from-lime-500 via-emerald-600 to-teal-700",
+ ring: "ring-lime-400/40",
+ text: "text-lime-400",
+ bg: "bg-lime-400/10",
+ border: "border-lime-400/20",
+ },
+];
+
+export const hashString = (value: string): number => {
+ let hash = 0;
+ for (let i = 0; i < value.length; i += 1) {
+ hash = (hash << 5) - hash + value.charCodeAt(i);
+ hash |= 0;
+ }
+ return Math.abs(hash);
+};
+
+export const getServicePalette = (service: string) => {
+ const palette =
+ servicePalettes[hashString(service) % servicePalettes.length] ??
+ servicePalettes[0];
+ return palette;
+};
diff --git a/otel-ui/src/lib/routes.ts b/otel-ui/src/lib/routes.ts
new file mode 100644
index 0000000..ccaa917
--- /dev/null
+++ b/otel-ui/src/lib/routes.ts
@@ -0,0 +1,31 @@
+// 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
+// exactly the view you requested, and URLs are shareable/bookmarkable.
+// The Go server's SPA fallback serves index.html for all of these paths.
+export const TAB_PATHS: Record = {
+ "/issues": "tab0",
+ "/patterns": "tab8",
+ "/rollouts": "tab9",
+ "/traces": "tab1",
+ "/metrics": "tab2",
+ "/logs": "tab3",
+ "/setup": "tab4",
+ "/trends": "tab5",
+ "/search": "tab6",
+ "/frontend": "tab7",
+ "/rum": "tab7",
+}
+
+
+/** Tab value for a location pathname, or null when the path isn't a tab. */
+export function tabForPath(path: string): string | null {
+ return TAB_PATHS[path] ?? null
+}
+
+/** Canonical path for a tab value, or null when the tab has no route. */
+export function pathForTab(tab: string): string | null {
+ for (const [path, value] of Object.entries(TAB_PATHS)) {
+ if (value === tab) return path
+ }
+ return null
+}
diff --git a/otel-ui/src/lib/timeUtils.ts b/otel-ui/src/lib/timeUtils.ts
new file mode 100644
index 0000000..51deee3
--- /dev/null
+++ b/otel-ui/src/lib/timeUtils.ts
@@ -0,0 +1,25 @@
+export function formatDuration(nano: number): string {
+ if (nano >= 3600000000000) { // 1 hour in nanoseconds
+ return `${(nano / 3600000000000).toFixed(1)}h`;
+ } else if (nano >= 60000000000) { // 1 minute
+ return `${(nano / 60000000000).toFixed(1)}m`;
+ } else if (nano >= 1000000000) { // 1 second
+ return `${(nano / 1000000000).toFixed(1)}s`;
+ } else if (nano >= 1000000) { // 1 millisecond
+ return `${(nano / 1000000).toFixed(1)}ms`;
+ } else if (nano >= 1000) { // 1 microsecond
+ return `${(nano / 1000).toFixed(1)}μs`;
+ } else {
+ return `${nano.toFixed(1)}ns`;
+ }
+}
+
+/** "just now", "2m ago", "3h ago", "5d ago" */
+export function formatRelativeTime(msEpoch: number): string {
+ const seconds = Math.max(0, (Date.now() - msEpoch) / 1000);
+ if (seconds < 60) return "just now";
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
+ return `${Math.floor(seconds / 86400)}d ago`;
+}
+
diff --git a/otel-ui/src/lib/types.ts b/otel-ui/src/lib/types.ts
new file mode 100644
index 0000000..dc42c92
--- /dev/null
+++ b/otel-ui/src/lib/types.ts
@@ -0,0 +1,268 @@
+// OpenTelemetry data types
+
+export interface OtelAttribute {
+ key: string;
+ value: AttributeValue;
+}
+
+export interface AttributeValue {
+ stringValue?: string;
+ intValue?: string;
+ doubleValue?: number;
+ boolValue?: boolean;
+}
+
+export interface SpanEvent {
+ name: string;
+ timeUnixNano: string;
+ attributes: OtelAttribute[];
+}
+
+export interface Span {
+ traceId: string;
+ spanId: string;
+ parentSpanId?: string;
+ name: string;
+ kind: number;
+ startTimeUnixNano: string;
+ endTimeUnixNano: string;
+ attributes: OtelAttribute[];
+ events: SpanEvent[];
+ status: Record;
+ flags: number;
+}
+
+export interface ScopeSpan {
+ scope: {
+ name: string;
+ };
+ spans: Span[];
+}
+
+export interface ResourceSpan {
+ resource: {
+ attributes: OtelAttribute[];
+ };
+ schemaUrl?: string;
+ scopeSpans: ScopeSpan[];
+}
+
+export interface TraceData {
+ resourceSpans: ResourceSpan[];
+}
+
+export interface MetricDataPoint {
+ asInt?: string;
+ asDouble?: number;
+ attributes: OtelAttribute[];
+ timeUnixNano?: string;
+ pod?: string;
+ // Histogram-specific fields
+ bucketCounts?: string[];
+ count?: string;
+ exemplars?: Array<{
+ asInt?: string;
+ asDouble?: number;
+ spanId: string;
+ timeUnixNano: string;
+ traceId: string;
+ }>;
+ explicitBounds?: number[];
+ max?: number;
+ min?: number;
+ startTimeUnixNano?: string;
+ sum?: number;
+}
+
+export interface Metric {
+ name: string;
+ gauge?: {
+ dataPoints: MetricDataPoint[];
+ };
+ sum?: {
+ dataPoints: MetricDataPoint[];
+ };
+ histogram?: {
+ dataPoints: MetricDataPoint[];
+ };
+}
+
+export interface ScopeMetric {
+ scope: {
+ name: string;
+ };
+ metrics: Metric[];
+}
+
+export interface ResourceMetric {
+ resource: {
+ attributes: OtelAttribute[];
+ };
+ scopeMetrics: ScopeMetric[];
+}
+
+export interface MetricData {
+ resourceMetrics: ResourceMetric[];
+}
+
+export interface LogRecord {
+ uid: string;
+ timeUnixNano: string;
+ body: {
+ stringValue: string;
+ };
+ attributes: OtelAttribute[];
+ severityNumber?: number;
+ severityText?: string;
+ traceId?: string;
+ spanId?: string;
+}
+
+export interface ScopeLog {
+ scope: {
+ name: string;
+ };
+ logRecords: LogRecord[];
+}
+
+export interface ResourceLog {
+ resource: {
+ attributes: OtelAttribute[];
+ };
+ scopeLogs: ScopeLog[];
+}
+
+export type LogEntry = ResourceLog & {
+ id: string;
+}
+
+export interface LogData {
+ resourceLogs: ResourceLog[];
+}
+
+export interface OtelTraceData {
+ type: "traces";
+
+ data: TraceData;
+}
+
+export interface OtelMetricData {
+ type: "metrics";
+
+ data: MetricData;
+}
+
+export interface OtelLogData {
+ type: "logs";
+
+ data: LogData;
+}
+
+export type OtelHistoryMessage = OtelMessage & {
+ timestamp: number;
+};
+
+export type OtelMessage = OtelTraceData | OtelMetricData | OtelLogData;
+
+// Deduplicated errors derived server-side from traces/logs. See
+// GET /api/issues and GET /api/issues/{id} in the go-otel backend.
+export interface IssueSample {
+ timestamp: number;
+ traceId?: string;
+ spanId?: string;
+ message: string;
+ url?: string;
+ stacktrace?: string;
+ resolvedStacktrace?: string;
+ httpMethod?: string;
+ httpRoute?: string;
+ httpStatus?: number;
+ userAgent?: string;
+ sourceIp?: string;
+ release?: string;
+ environment?: string;
+ /** Raw JSON breadcrumb trail captured by otel-rum.js (frontend issues). */
+ breadcrumbs?: string;
+}
+
+export interface Issue {
+ id: string;
+ title: string;
+ kind: "span_error" | "log_error" | "frontend_error" | string;
+ service: string;
+ count: number;
+ firstSeen: number;
+ lastSeen: number;
+ affectedServices: string[];
+ samples: IssueSample[];
+ /** UTC hour-bucket ("2026-09-17T14:00") -> occurrence count, last 48h. */
+ trend?: Record;
+ status?: "open" | "resolved" | "ignored" | string;
+ resolvedAt?: number;
+ normalizedTitle?: string;
+ patternId?: string;
+ patternSignature?: string;
+}
+
+export interface SimilarIssue {
+ issue: Issue;
+ score: number;
+ reasons: string[];
+ decision?: "same" | "different" | string;
+}
+
+export interface IssuePattern {
+ id: string;
+ signature: string;
+ service: string;
+ kind: string;
+ issueCount: number;
+ occurrenceCount: number;
+ lastSeen: number;
+ issueIds: string[];
+}
+
+export interface Rollout {
+ id: string;
+ namespace: string;
+ workload: string;
+ service?: string;
+ release?: string;
+ revision?: string;
+ image?: string;
+ phase: "progressing" | "completed" | "failed" | string;
+ reason?: string;
+ message?: string;
+ startedAt: number;
+ completedAt?: number;
+ lastSeen: number;
+ generation: number;
+ desiredReplicas: number;
+ updatedReplicas: number;
+ availableReplicas: number;
+ readyReplicas: number;
+}
+
+export interface RolloutImpact {
+ configured: boolean;
+ rollout: Rollout;
+ points: Array<{ timestamp: number; value: string }>;
+}
+
+// ---- Aggregate stats (ClickHouse-backed /api/stats/*) -------------------
+
+export interface StatsPoint {
+ /** ISO-ish bucket label from the server, e.g. "2026-09-17 14:00:00". */
+ bucket: string;
+ total: number;
+ errors: number;
+}
+
+export interface ErrorStatsResponse {
+ granularity: "hour" | "day";
+ points: StatsPoint[];
+}
+
+export interface ReleaseStatsResponse {
+ series: Record;
+}
diff --git a/otel-ui/src/main.tsx b/otel-ui/src/main.tsx
new file mode 100644
index 0000000..4f7310c
--- /dev/null
+++ b/otel-ui/src/main.tsx
@@ -0,0 +1,31 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { Router } from "wouter";
+import "./index.css";
+import App from "./App.tsx";
+import { ObservabilityProvider } from "./contexts/ObservabilityContextProvider.tsx";
+import { ViewErrorBoundary } from "./components/ViewErrorBoundary";
+
+// Use the project's own browser telemetry for UI errors, failed requests and
+// Web Vitals. The script is loaded before this module in index.html.
+const rum = (window as Window & {
+ OtelRUM?: { init: (config: Record) => unknown }
+}).OtelRUM;
+rum?.init({
+ serviceName: "otel-ui",
+ release: (import.meta.env.VITE_APP_RELEASE as string | undefined) ?? "dev",
+ baseUrl: window.location.origin,
+ endpoint: "/v1/logs",
+});
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+
+
+ ,
+);
diff --git a/otel-ui/src/styles/globals.css b/otel-ui/src/styles/globals.css
new file mode 100644
index 0000000..dc2aea1
--- /dev/null
+++ b/otel-ui/src/styles/globals.css
@@ -0,0 +1,125 @@
+@import 'tailwindcss';
+@import 'tw-animate-css';
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.145 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.145 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.985 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.396 0.141 25.723);
+ --destructive-foreground: oklch(0.637 0.237 25.331);
+ --border: oklch(0.269 0 0);
+ --input: oklch(0.269 0 0);
+ --ring: oklch(0.439 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(0.269 0 0);
+ --sidebar-ring: oklch(0.439 0 0);
+}
+
+@theme inline {
+ --font-sans: 'Geist', 'Geist Fallback';
+ --font-mono: 'Geist Mono', 'Geist Mono Fallback';
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/otel-ui/tsconfig.app.json b/otel-ui/tsconfig.app.json
new file mode 100644
index 0000000..a9b5a59
--- /dev/null
+++ b/otel-ui/tsconfig.app.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/otel-ui/tsconfig.json b/otel-ui/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/otel-ui/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/otel-ui/tsconfig.node.json b/otel-ui/tsconfig.node.json
new file mode 100644
index 0000000..8a67f62
--- /dev/null
+++ b/otel-ui/tsconfig.node.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/otel-ui/vite.config.ts b/otel-ui/vite.config.ts
new file mode 100644
index 0000000..e21d795
--- /dev/null
+++ b/otel-ui/vite.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ build: {
+ // Upload these maps to the receiver during deployment; do not publish
+ // them with the static UI.
+ sourcemap: true,
+ },
+ server: {
+ proxy: {
+ "/api": {
+ target: "http://localhost:8080",
+ changeOrigin: true,
+ },
+ "/ws": {
+ target: "ws://localhost:8080",
+ ws: true,
+ },
+ },
+ },
+});
diff --git a/scripts/upload-sourcemap.sh b/scripts/upload-sourcemap.sh
new file mode 100755
index 0000000..41ffe4c
--- /dev/null
+++ b/scripts/upload-sourcemap.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+: "${OTEL_URL:?set OTEL_URL, for example https://otel.example.com}"
+: "${RELEASE:?set RELEASE to the exact VITE_APP_RELEASE used at build time}"
+
+bundle="${1:?usage: RELEASE=... OTEL_URL=... $0 dist/assets/index-HASH.js}"
+map="${bundle}.map"
+test -f "$bundle" || { echo "bundle not found: $bundle" >&2; exit 1; }
+test -f "$map" || { echo "source map not found: $map" >&2; exit 1; }
+
+source_path="/${bundle#*/dist/}"
+curl --fail-with-body --silent --show-error \
+ -X POST "${OTEL_URL%/}/api/sourcemaps" \
+ -F "release=${RELEASE}" \
+ -F "source=${source_path}" \
+ -F "file=@${map};type=application/json"
+echo "uploaded ${source_path} for ${RELEASE}"
diff --git a/static/otel-rum.js b/static/otel-rum.js
index d2aebaf..547cceb 100644
--- a/static/otel-rum.js
+++ b/static/otel-rum.js
@@ -1,10 +1,11 @@
/*!
- * otel-rum.js - minimal browser error/RUM collector for go-otel.
+ * otel-rum.js - browser error/RUM collector for go-otel.
*
- * Captures uncaught errors, unhandled promise rejections and failed
- * resource loads, and ships them as OTLP log records to this server's
- * existing /v1/logs endpoint (no separate ingestion path needed - the
- * backend's issue view picks these up like any other error log).
+ * Captures uncaught errors, unhandled promise rejections, failed resource
+ * loads, failing HTTP requests and Web Vitals (LCP/INP/CLS/TTFB/FCP), and
+ * ships them as OTLP log records to this server's existing /v1/logs endpoint
+ * (no separate ingestion path needed - the backend's issue view picks these
+ * up like any other error log).
*
* Also exposes a `traceHeaders()` helper so outgoing `fetch`/XHR calls can
* carry a W3C `traceparent` header. If the backend service is itself
@@ -16,7 +17,10 @@
* Usage:
*
*
*
- * Explicitly out of scope for v1: source map resolution (stack traces stay
- * minified), session replay, PII scrubbing. Don't route sensitive data
- * through breadcrumbs/attributes.
+ * Events are batched client-side (flushed on pagehide via keepalive fetch/sendBeacon) and
+ * rate-limited, so a runaway error loop cannot hammer the collector.
+ *
+ * Source-map resolution is performed server-side by go-otel when a matching
+ * release map has been uploaded. Session replay and PII scrubbing remain out
+ * of scope; don't route sensitive data through breadcrumbs/attributes.
*/
(function (global) {
'use strict';
+ var DEFAULT_BASE_URL = 'https://otel.k6n.net';
+
+ // Find the loading script so declarative data-* configuration keeps working.
+ try {
+ var scriptEl = (typeof document !== 'undefined') ? (document.currentScript || (function() {
+ var scripts = document.getElementsByTagName('script');
+ for (var i = scripts.length - 1; i >= 0; i--) {
+ if (scripts[i].src && scripts[i].src.indexOf('otel-rum') !== -1) return scripts[i];
+ }
+ return null;
+ })()) : null;
+ } catch (e) {}
+
+ var rawFetch = typeof global.fetch === 'function' ? global.fetch : null;
+ var rawXHR = typeof global.XMLHttpRequest === 'function' ? global.XMLHttpRequest : null;
+
function randomHex(len) {
var chars = '0123456789abcdef';
var out = '';
@@ -73,17 +96,51 @@
function init(config) {
config = config || {};
- var endpoint = config.endpoint || '/v1/logs';
- var serviceName = config.serviceName || 'frontend';
- var environment = config.environment || '';
- var release = config.release || '';
- var ingestKey = config.ingestKey || '';
+ var scriptServiceName = (scriptEl && (scriptEl.getAttribute('data-service-name') || scriptEl.getAttribute('data-service'))) || '';
+ var scriptBaseUrl = (scriptEl && scriptEl.getAttribute('data-base-url')) || '';
+ var scriptEndpoint = (scriptEl && scriptEl.getAttribute('data-endpoint')) || '';
+ var scriptIngestKey = (scriptEl && scriptEl.getAttribute('data-ingest-key')) || '';
+
+ // Collector base URL resolution:
+ // 1. Explicit config.baseUrl or scriptEl data-base-url
+ // 2. Production collector: https://otel.k6n.net
+ // Do not derive this from the page or script origin: the script may be
+ // bundled or self-hosted while logs still belong at the collector.
+ var baseUrl = (config.baseUrl || scriptBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
+ var endpoint = config.endpoint || scriptEndpoint;
+ if (!endpoint) {
+ endpoint = baseUrl + '/v1/logs';
+ } else if (!/^https?:\/\//i.test(endpoint) && !endpoint.startsWith('//')) {
+ endpoint = baseUrl + '/' + endpoint.replace(/^\/+/, '');
+ }
+
+ function isCollectorUrl(u) {
+ if (!u) return false;
+ var s = String(u);
+ if (endpoint && s.indexOf(endpoint) === 0) return true;
+ if (s.indexOf('/v1/logs') !== -1 || s.indexOf('/v1/traces') !== -1 || s.indexOf('/v1/metrics') !== -1) return true;
+ return false;
+ }
+
+ var serviceName = config.serviceName || scriptServiceName || 'frontend';
+ var environment = config.environment || (scriptEl && scriptEl.getAttribute('data-environment')) || '';
+ var release = config.release || (scriptEl && scriptEl.getAttribute('data-release')) || '';
+ var ingestKey = config.ingestKey || scriptIngestKey || '';
var sampleRate = typeof config.sampleRate === 'number' ? config.sampleRate : 1;
var maxBreadcrumbs = config.maxBreadcrumbs || 20;
var captureConsoleErrors = config.captureConsoleErrors !== false;
var autoInstrumentFetch = config.autoInstrumentFetch !== false;
+ var autoInstrumentXhr = config.autoInstrumentXhr !== false;
+ var trackSpaRoutes = config.trackSpaRoutes !== false;
+ var reportHttpFailures = config.reportHttpFailures !== false;
+ var captureWebVitals = config.captureWebVitals !== false;
var injectTraceHeaders = config.injectTraceHeaders !== false;
var traceHeaderOrigins = config.traceHeaderOrigins; // optional array; defaults to same-origin only
+ // Client-side rate limiting: max events per rolling minute.
+ var maxEventsPerMinute = config.maxEventsPerMinute || 60;
+ // Batch size / flush interval for queued records.
+ var batchSize = config.batchSize || 10;
+ var flushIntervalMs = config.flushIntervalMs || 5000;
var breadcrumbs = [];
// Best-effort correlation: the most recently generated trace id is
@@ -92,37 +149,128 @@
// not a real per-async-context propagation mechanism.
var lastTraceId = '';
- function addBreadcrumb(category, message) {
- breadcrumbs.push({ t: Date.now(), category: String(category), message: String(message) });
- if (breadcrumbs.length > maxBreadcrumbs) {
- breadcrumbs.shift();
+ // Session id: survives reloads within the tab's lifetime, so server
+ //-side issue views can group frontend errors by browsing session.
+ var sessionId = '';
+ try {
+ sessionId = global.sessionStorage.getItem('otel-rum-session');
+ if (!sessionId) {
+ sessionId = randomHex(16);
+ global.sessionStorage.setItem('otel-rum-session', sessionId);
+ }
+ } catch (e) {
+ sessionId = randomHex(16); // storage blocked (privacy mode etc.)
+ }
+
+ // --- Client-side queue, batching and rate limiting -----------------
+
+ var queue = [];
+ var flushTimer = null;
+ var sentTimestamps = [];
+ var eventsDropped = 0;
+
+ function underRateLimit() {
+ var now = Date.now();
+ // Drop timestamps older than the rolling 60s window.
+ while (sentTimestamps.length && now - sentTimestamps[0] > 60000) {
+ sentTimestamps.shift();
+ }
+ if (sentTimestamps.length >= maxEventsPerMinute) return false;
+ sentTimestamps.push(now);
+ return true;
+ }
+
+ function enqueue(record) {
+ queue.push(record);
+ if (queue.length >= batchSize) {
+ flush();
+ return;
+ }
+ if (!flushTimer) {
+ flushTimer = global.setTimeout(flush, flushIntervalMs);
}
}
- // traceHeaders returns a headers object carrying a fresh (or
- // continued) W3C traceparent, ready to spread into any HTTP client's
- // request headers - fetch, XHR, axios, etc.
- function traceHeaders(existingTraceId) {
- var traceId = existingTraceId || newTraceId();
- var spanId = newSpanId();
- lastTraceId = traceId;
- return { traceparent: '00-' + traceId + '-' + spanId + '-01' };
+ function flush() {
+ if (flushTimer) {
+ global.clearTimeout(flushTimer);
+ flushTimer = null;
+ }
+ if (!queue.length) return;
+ var records = queue.splice(0, queue.length);
+ ship(records);
}
- function send(severity, message, attrs, traceId) {
- if (sampleRate < 1 && Math.random() > sampleRate) return;
+ // ship sends already-built log records in one OTLP payload. Uses
+ // sendBeacon when available (survives page unload), else keepalive
+ // fetch.
+ function ship(records) {
+ var payload = {
+ resourceLogs: [
+ {
+ resource: { attributes: toOtlpAttributes({ 'service.name': serviceName }) },
+ scopeLogs: [{ scope: { name: 'otel-rum' }, logRecords: records }]
+ }
+ ]
+ };
+ var body = JSON.stringify(payload);
+ var url = endpoint;
+ if (ingestKey) {
+ url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey);
+ }
+
+ // Use uninstrumented rawFetch with keepalive: true and mode: 'cors'
+ // to avoid intercepting and looping on our own telemetry requests.
+ var fetchFn = rawFetch || (typeof global.fetch === 'function' ? global.fetch : null);
+ if (fetchFn) {
+ var fetchHeaders = { 'Content-Type': 'application/json' };
+ if (ingestKey) {
+ fetchHeaders['X-Otel-Ingest-Key'] = ingestKey;
+ }
+ fetchFn.call(global, url, {
+ method: 'POST',
+ mode: 'cors',
+ headers: fetchHeaders,
+ body: body,
+ keepalive: true
+ }).catch(function () {
+ if (global.navigator && typeof global.navigator.sendBeacon === 'function') {
+ try {
+ var blob = new global.Blob([body], { type: 'text/plain;charset=UTF-8' });
+ global.navigator.sendBeacon(url, blob);
+ } catch (e) {}
+ }
+ });
+ return;
+ }
+
+ if (global.navigator && typeof global.navigator.sendBeacon === 'function') {
+ try {
+ var blob = new global.Blob([body], { type: 'text/plain;charset=UTF-8' });
+ global.navigator.sendBeacon(url, blob);
+ } catch (e) {}
+ }
+ }
+
+ var severityNumbers = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17, FATAL: 21 };
+
+ function buildRecord(severity, message, attrs, traceId) {
var record = {
timeUnixNano: String(Date.now() * 1e6),
severityText: severity,
- severityNumber: severity === 'FATAL' ? 21 : 17,
+ severityNumber: severityNumbers[severity] || 17,
body: { stringValue: String(message).slice(0, 2000) },
attributes: toOtlpAttributes(
Object.assign(
+ {},
+ contextAttrs,
{
source: 'frontend',
'browser.url': global.location ? global.location.href : '',
'browser.userAgent': global.navigator ? global.navigator.userAgent : '',
+ 'browser.viewport': global.innerWidth ? global.innerWidth + 'x' + global.innerHeight : '',
+ 'session.id': sessionId,
'service.release': release,
'service.environment': environment,
breadcrumbs: breadcrumbs.length ? JSON.stringify(breadcrumbs) : undefined
@@ -134,33 +282,114 @@
if (traceId) {
record.traceId = traceId;
}
+ return record;
+ }
- var payload = {
- resourceLogs: [
- {
- resource: { attributes: toOtlpAttributes({ 'service.name': serviceName }) },
- scopeLogs: [{ scope: { name: 'otel-rum' }, logRecords: [record] }]
- }
- ]
- };
-
- var body = JSON.stringify(payload);
- var url = endpoint;
- if (ingestKey) {
- url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey);
+ function send(severity, message, attrs, traceId) {
+ if (sampleRate < 1 && Math.random() > sampleRate) return;
+ if (!underRateLimit()) {
+ eventsDropped++;
+ return;
}
+ enqueue(buildRecord(severity, message, attrs, traceId));
+ }
+
+ // Web Vitals via PerformanceObserver. Values are reported as INFO logs
+ // tagged rum.vital=; the server can chart/aggregate them like
+ // any other telemetry. Each vital fires once when it reaches its
+ // final value (plus a first-input proxy for INP on older browsers).
+ function observeVitals() {
+ if (typeof global.PerformanceObserver !== 'function') return;
+
+ function report(name, value, extra) {
+ var attrs = Object.assign({ 'rum.vital': name, 'rum.value': Math.round(value) }, extra || {});
+ // Vitals are telemetry, not errors: bypass the error rate limit
+ // but keep sampling.
+ if (sampleRate < 1 && Math.random() > sampleRate) return;
+ enqueue(buildRecord('INFO', name + ' ' + Math.round(value), attrs, ''));
+ }
+
+ function onPaint(list) {
+ list.getEntries().forEach(function (entry) {
+ if (entry.name === 'first-contentful-paint') report('FCP', entry.startTime);
+ });
+ }
+
+ function onLCP(list) {
+ var entries = list.getEntries();
+ var last = entries[entries.length - 1];
+ if (last) report('LCP', last.startTime, { 'rum.element': last.element && last.element.tagName });
+ }
+
+ function onCLS() {
+ var cls = 0;
+ var lastReport = 0;
+ var observer = new global.PerformanceObserver(function (list) {
+ list.getEntries().forEach(function (entry) {
+ if (!entry.hadRecentInput) cls += entry.value;
+ });
+ // Throttle: report at most once per 10s, plus a final value on
+ // pagehide, instead of once per layout shift.
+ var now = Date.now();
+ if (now - lastReport > 10000) {
+ lastReport = now;
+ report('CLS', Math.round(cls * 1000) / 1000);
+ }
+ clsFinal = function () {
+ report('CLS', Math.round(cls * 1000) / 1000);
+ clsFinal = null;
+ };
+ });
+ try {
+ observer.observe({ type: 'layout-shift', buffered: true });
+ } catch (e) {
+ /* unsupported */
+ clsFinal = null;
+ }
+ }
+
+ function onINP(entry) {
+ var duration = entry.duration;
+ var interactionId = entry.interactionId;
+ if (!interactionId) return;
+ if (duration > (observerState.maxINP || 0)) {
+ observerState.maxINP = duration;
+ report('INP', duration);
+ }
+ }
+
+ var observerState = {};
try {
- if (global.navigator && typeof global.navigator.sendBeacon === 'function') {
- var blob = new global.Blob([body], { type: 'application/json' });
- if (global.navigator.sendBeacon(url, blob)) return;
- }
- } catch (e) {
- // fall through to fetch
- }
- if (typeof global.fetch === 'function') {
- global.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body, keepalive: true }).catch(function () {});
- }
+ new global.PerformanceObserver(onPaint).observe({ type: 'paint', buffered: true });
+ } catch (e) {}
+ try {
+ new global.PerformanceObserver(onLCP).observe({ type: 'largest-contentful-paint', buffered: true });
+ } catch (e) {}
+ onCLS();
+ try {
+ new global.PerformanceObserver(function (list) {
+ list.getEntries().forEach(onINP);
+ }).observe({ type: 'event', buffered: true, durationThreshold: 40 });
+ } catch (e) {}
+
+ // Navigation timing: TTFB.
+ try {
+ var nav = performance.getEntriesByType('navigation')[0];
+ if (nav && nav.responseStart) {
+ report('TTFB', nav.responseStart);
+ }
+ } catch (e) {}
+ }
+
+ // traceHeaders returns a headers object carrying a fresh (or
+ // continued) W3C traceparent, ready to spread into any HTTP client's
+ // request headers - fetch, XHR, axios, etc.
+ function traceHeaders(existingTraceId) {
+ var traceId = existingTraceId || newTraceId();
+ var spanId = newSpanId();
+ lastTraceId = traceId;
+ return { traceparent: '00-' + traceId + '-' + spanId + '-01' };
}
function captureException(err, extra) {
@@ -184,6 +413,16 @@
send(severity || 'ERROR', message, extra, lastTraceId);
}
+ function setContext(context) {
+ // Attach stable context (e.g. { user: 'u-123', plan: 'pro' }) to
+ // every subsequent event. Values become OTLP attributes prefixed
+ // with "context.".
+ contextAttrs = Object.assign({}, contextAttrs, context || {});
+ flush(); // ensure earlier queued events don't mix contexts ambiguously
+ }
+ var contextAttrs = {};
+ var clsFinal = null;
+
function onError(event) {
// Resource load errors (img/script/link) surface as an ErrorEvent-less
// Event targeting the failed element; script errors carry a message.
@@ -233,45 +472,214 @@
};
}
+ function shouldInject(url) {
+ if (!injectTraceHeaders) return false;
+ if (!traceHeaderOrigins) return isSameOrigin(url);
+ return traceHeaderOrigins.some(function (o) {
+ return url.indexOf(o) === 0;
+ });
+ }
+
+ // httpFailureReport reports a failed HTTP round-trip as an ERROR event,
+ // so backend 5xx/4xx responses show up in the Issues view even when
+ // the app code swallows them.
+ function httpFailureReport(kind, method, url, status, traceId, extra) {
+ // Issue refresh failures must not create new issues notifications:
+ // otherwise an expired session can form an API-error feedback loop.
+ try {
+ if (new global.URL(url, global.location.href).pathname === '/api/issues') return;
+ } catch (e) {}
+ var attrs = Object.assign(
+ {
+ 'exception.type': 'HttpFailure',
+ 'http.request.method': method,
+ 'url.full': String(url).slice(0, 500),
+ 'http.response.status_code': status === undefined || status === 0 ? '' : String(status)
+ },
+ extra || {}
+ );
+ captureMessage(
+ method + ' ' + url + ' failed with ' + (status === undefined || status === 0 ? 'network error' : 'HTTP ' + status),
+ 'ERROR',
+ attrs
+ );
+ }
+
if (autoInstrumentFetch && typeof global.fetch === 'function') {
var originalFetch = global.fetch;
global.fetch = function (input, requestInit) {
var url = typeof input === 'string' ? input : (input && input.url) || '';
+ if (isCollectorUrl(url)) {
+ return originalFetch.call(global, input, requestInit);
+ }
var method = (requestInit && requestInit.method) || (input && input.method) || 'GET';
+ var requestTraceId = '';
- if (injectTraceHeaders) {
- var allowed = traceHeaderOrigins ? traceHeaderOrigins.some(function (o) { return url.indexOf(o) === 0; }) : isSameOrigin(url);
- if (allowed) {
- requestInit = requestInit || {};
- var headers = new global.Headers(requestInit.headers || (input && input.headers) || {});
- var th = traceHeaders();
- headers.set('traceparent', th.traceparent);
- requestInit = Object.assign({}, requestInit, { headers: headers });
- }
+ if (shouldInject(url)) {
+ requestInit = requestInit || {};
+ var headers = new global.Headers(requestInit.headers || (input && input.headers) || {});
+ var th = traceHeaders();
+ headers.set('traceparent', th.traceparent);
+ requestTraceId = th.traceparent.slice(3, 35); // trace id portion
+ requestInit = Object.assign({}, requestInit, { headers: headers });
}
return originalFetch.call(global, input, requestInit).then(
function (res) {
addBreadcrumb('fetch', method + ' ' + url + ' -> ' + res.status);
+ if (reportHttpFailures && res.status >= 400) {
+ httpFailureReport('fetch', method, url, res.status, requestTraceId);
+ }
return res;
},
function (err) {
addBreadcrumb('fetch', method + ' ' + url + ' -> error');
+ if (reportHttpFailures) {
+ httpFailureReport('fetch', method, url, 0, requestTraceId);
+ }
throw err;
}
);
};
}
- return {
+ // XHR instrumentation: trace-header injection + breadcrumbs + failure
+ // reporting, so legacy code paths (axios older versions, jQuery, etc.)
+ // get the same visibility as fetch.
+ if (autoInstrumentXhr && typeof global.XMLHttpRequest === 'function') {
+ var originalOpen = global.XMLHttpRequest.prototype.open;
+ var originalSend = global.XMLHttpRequest.prototype.send;
+ var originalSetRequestHeader = global.XMLHttpRequest.prototype.setRequestHeader;
+
+ global.XMLHttpRequest.prototype.open = function (method, url) {
+ if (isCollectorUrl(url)) {
+ return originalOpen.apply(this, arguments);
+ }
+ try {
+ this.__rum = { method: String(method), url: String(url) };
+ if (shouldInject(String(url))) {
+ var th = traceHeaders();
+ this.__rum.traceId = th.traceparent.slice(3, 35);
+ originalSetRequestHeader.call(this, 'traceparent', th.traceparent);
+ }
+ } catch (e) {
+ /* ignore */
+ }
+ return originalOpen.apply(this, arguments);
+ };
+
+ global.XMLHttpRequest.prototype.send = function () {
+ var xhr = this;
+ var info = xhr.__rum || {};
+ if (info.url) {
+ var origHandler = xhr.onreadystatechange;
+ xhr.onreadystatechange = function () {
+ try {
+ if (xhr.readyState === 4) {
+ addBreadcrumb('xhr', info.method + ' ' + info.url + ' -> ' + xhr.status);
+ if (reportHttpFailures && (xhr.status === 0 || xhr.status >= 400)) {
+ httpFailureReport('xhr', info.method, info.url, xhr.status, info.traceId);
+ }
+ }
+ } catch (e) {
+ /* ignore */
+ }
+ if (origHandler) return origHandler.apply(this, arguments);
+ };
+ }
+ return originalSend.apply(this, arguments);
+ };
+ }
+
+ // SPA route-change breadcrumbs: history.pushState/replaceState and
+ // popstate/hashes get recorded so an error report shows which route
+ // transitions preceded it.
+ if (trackSpaRoutes && global.history) {
+ function recordRoute(kind) {
+ addBreadcrumb('navigation', kind + ' ' + (global.location ? global.location.pathname + global.location.search : ''));
+ }
+ var originalPush = global.history.pushState;
+ var originalReplace = global.history.replaceState;
+ if (typeof originalPush === 'function') {
+ global.history.pushState = function () {
+ var result = originalPush.apply(this, arguments);
+ try {
+ recordRoute('pushState');
+ } catch (e) {}
+ return result;
+ };
+ }
+ if (typeof originalReplace === 'function') {
+ global.history.replaceState = function () {
+ var result = originalReplace.apply(this, arguments);
+ try {
+ recordRoute('replaceState');
+ } catch (e) {}
+ return result;
+ };
+ }
+ global.addEventListener('popstate', function () {
+ try {
+ recordRoute('popstate');
+ } catch (e) {}
+ });
+ }
+
+ // Flush pending events when the page is being hidden or unloaded so
+ // errors that happen right before navigation are not lost, and emit
+ // the final CLS value.
+ global.addEventListener('pagehide', function () {
+ if (clsFinal) clsFinal();
+ flush();
+ });
+ global.addEventListener('visibilitychange', function () {
+ if (global.document && global.document.visibilityState === 'hidden') {
+ if (clsFinal) clsFinal();
+ flush();
+ }
+ });
+
+ // First paint: report vitals after observers have a chance to buffer.
+ if (captureWebVitals) {
+ observeVitals();
+ }
+
+ function addBreadcrumb(category, message) {
+ breadcrumbs.push({ t: Date.now(), category: String(category), message: String(message) });
+ if (breadcrumbs.length > maxBreadcrumbs) {
+ breadcrumbs.shift();
+ }
+ }
+
+ var instance = {
captureException: captureException,
captureMessage: captureMessage,
addBreadcrumb: addBreadcrumb,
+ setContext: setContext,
+ flush: flush,
newTraceId: newTraceId,
newSpanId: newSpanId,
traceHeaders: traceHeaders
};
+
+ // Convenience handle for manual/console verification:
+ // OtelRUM.last.captureMessage('test from console').
+ global.OtelRUM.last = instance;
+ return instance;
}
- global.OtelRUM = { init: init, newTraceId: newTraceId, newSpanId: newSpanId };
+ global.OtelRUM = { init: init, newTraceId: newTraceId, newSpanId: newSpanId, last: undefined };
+
+ // Auto-initialize if data attributes are declared on the