more otel
This commit is contained in:
1 parent
9620e3bfa6
commit
21f04d696b
88 files changed
+23419
-192
No files matched your search
@@ -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<string, SeverityMeta> = {
|
||||
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<string | null>(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 (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{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 (
|
||||
<div
|
||||
key={recordKey}
|
||||
className="group relative flex flex-col overflow-hidden rounded-xl border border-white/5 bg-slate-950/40 transition-all hover:border-white/10 hover:bg-slate-900/50"
|
||||
>
|
||||
{/* The Main Log Row */}
|
||||
<div
|
||||
onClick={() => 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]"
|
||||
>
|
||||
<div className={`absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b ${severity.gradient} opacity-40 transition-opacity group-hover:opacity-100`} />
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="shrink-0 text-slate-500 whitespace-nowrap ml-1">
|
||||
{formatTimestamp(record.timeUnixNano)}
|
||||
</span>
|
||||
|
||||
{/* Service Badge */}
|
||||
{(() => {
|
||||
const sName = getRecordServiceName(record.attributes ?? [])
|
||||
const palette = getServicePalette(sName)
|
||||
return (
|
||||
<span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider ${palette.bg} ${palette.text} ${palette.border} border`}>
|
||||
{sName}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Severity Badge */}
|
||||
<span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-gradient-to-r ${severity.gradient} text-slate-950`}>
|
||||
{severity.display}
|
||||
</span>
|
||||
|
||||
{/* Log Message */}
|
||||
<span className="flex-1 min-w-[300px] break-all text-slate-200">
|
||||
{bodyMessage}
|
||||
</span>
|
||||
|
||||
{/* Action Icons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{record.traceId && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
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"
|
||||
>
|
||||
<Activity className="h-2.5 w-2.5 group-hover/trace:animate-pulse" />
|
||||
TRACE:{record.traceId.slice(0, 6)}
|
||||
</button>
|
||||
)}
|
||||
{isExpanded ? <ChevronUp className="h-3 w-3 text-slate-500" /> : <ChevronDown className="h-3 w-3 text-slate-500" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expansion Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-white/5 bg-black/40 p-4 text-[12px]">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{(record.attributes ?? []).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Attributes</p>
|
||||
<div className="space-y-1">
|
||||
{(record.attributes ?? []).map(attr => (
|
||||
<div key={attr.key} className="flex gap-2">
|
||||
<span className="text-sky-400 shrink-0">{attr.key}:</span>
|
||||
<span className="text-slate-300 break-all">{formatAttributeValue(attr.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(record.resourceAttributes ?? []).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Resource</p>
|
||||
<div className="space-y-1">
|
||||
{(record.resourceAttributes ?? []).map(attr => (
|
||||
<div key={attr.key} className="flex gap-2">
|
||||
<span className="text-emerald-400 shrink-0">{attr.key}:</span>
|
||||
<span className="text-slate-400 break-all">{formatAttributeValue(attr.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-white/5 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex gap-4 text-[10px] text-slate-500">
|
||||
<span>Scope: {record.scope || "unknown"}</span>
|
||||
{record.spanId && <span>Span ID: {record.spanId}</span>}
|
||||
{record.traceId && <span>Trace ID: {record.traceId}</span>}
|
||||
</div>
|
||||
|
||||
{record.traceId && (
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Activity className="h-3 w-3" />
|
||||
Jump to Span in Trace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
import { StructuredLogTerminal } from "./StructuredLogTerminal"
|
||||
|
||||
const LogVisualizer = ({ logs }: { logs: LogEntry[] }) => {
|
||||
return <StructuredLogTerminal logs={logs} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<TelemetryFilters />
|
||||
<Tabs.Root
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<Tabs.List
|
||||
className="flex gap-8 border-b border-white/10 pb-2 mb-2 w-full overflow-x-auto"
|
||||
aria-label="Telemetry explorer views"
|
||||
>
|
||||
{tabItems.map(({ value, label, Icon, count }) => (
|
||||
<Tabs.Trigger
|
||||
key={value}
|
||||
value={value}
|
||||
id={`tab-trigger-${value}`}
|
||||
aria-label={`${label} tab`}
|
||||
className="group relative flex items-center gap-2.5 pb-2 text-sm font-semibold text-slate-400 outline-none transition-colors hover:text-slate-200 data-[state=active]:text-white"
|
||||
>
|
||||
<div className="absolute -bottom-[2px] left-0 right-0 h-0.5 bg-sky-400 opacity-0 transition-opacity group-data-[state=active]:opacity-100" />
|
||||
<Icon className="h-4 w-4 text-slate-500 group-data-[state=active]:text-sky-400" />
|
||||
<span aria-hidden="true">{label}</span>
|
||||
{count !== undefined && (
|
||||
<span className="inline-flex rounded-full bg-slate-800 px-2 py-0.5 text-[10px] font-bold text-slate-300 group-data-[state=active]:bg-sky-500/20 group-data-[state=active]:text-sky-300">
|
||||
{numberFormatter.format(count)}
|
||||
</span>
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
<Tabs.Content
|
||||
className="outline-none py-2"
|
||||
value="tab0"
|
||||
>
|
||||
{activeTab === "tab0" && (
|
||||
<ViewErrorBoundary>
|
||||
<IssuesVisualizer
|
||||
issues={issuesState.issues}
|
||||
loading={issuesState.loading}
|
||||
error={issuesState.error}
|
||||
refresh={issuesState.refresh}
|
||||
onStatusChange={handleIssueStatusChange}
|
||||
statusBusy={statusBusy}
|
||||
/>
|
||||
</ViewErrorBoundary>
|
||||
)}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
className="outline-none py-2"
|
||||
value="tab1"
|
||||
>
|
||||
{activeTab === "tab1" && <ViewErrorBoundary><TraceVisualization traces={traces} /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="outline-none py-2" value="tab8">
|
||||
{activeTab === "tab8" && <ViewErrorBoundary><PatternsVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="outline-none py-2" value="tab9">
|
||||
{activeTab === "tab9" && <ViewErrorBoundary><RolloutsVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
className="outline-none py-2"
|
||||
value="tab2"
|
||||
>
|
||||
{activeTab === "tab2" && <ViewErrorBoundary><MetricsVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
className="outline-none py-2"
|
||||
value="tab3"
|
||||
>
|
||||
{activeTab === "tab3" && <ViewErrorBoundary><LogVisualizer logs={logs} /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
className="outline-none py-2"
|
||||
value="tab7"
|
||||
>
|
||||
{activeTab === "tab7" && <ViewErrorBoundary><FrontendVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
className="outline-none py-2"
|
||||
value="tab4"
|
||||
>
|
||||
{activeTab === "tab4" && <ViewErrorBoundary><SetupPanel /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
{statsAvailable && (
|
||||
<Tabs.Content
|
||||
className="outline-none py-2"
|
||||
value="tab5"
|
||||
>
|
||||
{activeTab === "tab5" && <ViewErrorBoundary><TrendsVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
)}
|
||||
<Tabs.Content
|
||||
className="outline-none py-2"
|
||||
value="tab6"
|
||||
>
|
||||
{activeTab === "tab6" && <ViewErrorBoundary><SearchAndWatcher /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user