more otel
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 58s

This commit is contained in:
mats committed 2026-09-18 20:51:37 +00:00
1 parent 9620e3bfa6
commit 21f04d696b
88 files changed
+23419 -192

No files matched your search

@@ -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<string, LogEntry[]>
>({})
const [traces, setTraces] = useState<TraceData[]>([])
const [logs, setLogs] = useState<LogEntry[]>([])
const [metrics, setMetrics] = useState<MetricData[]>([])
const [isConnected, setIsConnected] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<number>(null)
// --- Real Streaming / Pause Buffer State ---
const [isStreaming, setIsStreaming] = useState(true)
const isStreamingRef = useRef(true)
const bufferedMessagesRef = useRef<OtelMessage[]>([])
const [bufferedCount, setBufferedCount] = useState(0)
// --- Issues live update counter ---
const [issuesVersion, setIssuesVersion] = useState(0)
// --- Latency filter (milliseconds) ---
const [minDurationMs, setMinDurationMs] = useState<number>(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<TimeRangeFilter | null>(null)
// --- Filtering State ---
const defaultExcludedAttributes = useCallback((): Record<string, Set<string>> => ({
"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<Set<string>>(() => {
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<Record<string, Set<string>>>(() => {
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<string, Set<string>> = {}
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<boolean>(() => {
if (typeof window === "undefined") return false
const saved = localStorage.getItem(SHOW_ERRORS_STORAGE_KEY)
return saved === "true"
})
const [searchQuery, setSearchQuery] = useState<string>(() => {
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<string, string[]> = {}
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<string | null>(null)
const [activeSpanId, setActiveSpanId] = useState<string | null>(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<string>(
() => 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<boolean>(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<Set<string>>(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<string>()
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<string>()
// 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<string, number> = {}
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<string, {
name: string;
totalSignals: number;
errorCount: number;
spansCount: number;
logsCount: number;
hasErrors: boolean;
}> = {}
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<OtelHistoryMessage[] | null>,
logsRes.json() as Promise<OtelHistoryMessage[] | null>,
metricsRes.json() as Promise<OtelHistoryMessage[] | null>,
])
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<OtelHistoryMessage[] | null>
})
.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 (
<ObservabilityContext.Provider
value={{
logs: filteredLogs,
metrics: filteredMetrics,
traces: filteredTraces,
unfocusedLogs,
unfocusedTraces,
logsByTraceId,
logCountBySpanId,
isConnected,
// Real stream controls
isStreaming,
setIsStreaming,
bufferedCount,
flushBufferedStream,
// Real time window & latency
timeWindow,
setTimeWindow,
selectedTimeRange,
setSelectedTimeRange,
minDurationMs,
setMinDurationMs,
// Discovered service health & issues updates
serviceHealth,
issuesVersion,
selectedServices,
toggleService,
excludedAttributes,
toggleExcludedValue,
removeExcludedValue,
clearAttributeKey,
resetFilters,
uniqueServices,
showErrorsOnly,
setShowErrorsOnly,
searchQuery,
setSearchQuery,
activeTraceId,
setActiveTraceId,
activeSpanId,
setActiveSpanId,
activeTab,
setActiveTab: setActiveTabWithRoute,
filtersExpanded,
setFiltersExpanded,
}}
>
{children}
</ObservabilityContext.Provider>
)
}
@@ -0,0 +1,88 @@
"use client";
import { createContext } from "react";
import type { LogEntry, MetricData, TraceData } from "../lib/types";
export interface FilterState {
selectedServices: Set<string>;
excludedAttributes: Record<string, Set<string>>;
}
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<string, LogEntry[]>;
logCountBySpanId: Record<string, number>;
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<string, ServiceHealthSummary>;
issuesVersion: number;
// Global filters
selectedServices: Set<string>;
toggleService: (service: string) => void;
excludedAttributes: Record<string, Set<string>>;
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);