"use client" import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from "react" import { useLocation } from "wouter" import { tabForPath, pathForTab, splitPath, joinPath } 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 { addFifoSet, appendFifo, partitionMessages, mergeLogsByTraceId, STREAM_LIMITS } 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) // Already inside this tab (possibly on a deep link): keep the sub-route. if (tabForPath(window.location.pathname) === tab) return const path = pathForTab(tab) if (path) navigate(path) }, [navigate], ) // Trace selection <-> /traces/[/]. The URL is adopted when // it names a trace; a bare /traces never clears the selection, because other // views set the trace first and switch tabs second. useEffect(() => { const { base, rest } = splitPath(location) if (base !== "/traces" || !rest[0]) return setActiveTraceId((prev) => (prev === rest[0] ? prev : rest[0])) setActiveSpanId((prev) => (prev === (rest[1] ?? null) ? prev : (rest[1] ?? null))) // eslint-disable-next-line react-hooks/exhaustive-deps -- only follow URL changes }, [location]) useEffect(() => { if (activeTab !== "tab1" || !activeTraceId) return const target = joinPath("/traces", activeSpanId ? [activeTraceId, activeSpanId] : [activeTraceId]) if (target !== window.location.pathname) navigate(target) // eslint-disable-next-line react-hooks/exhaustive-deps -- location is read imperatively }, [activeTab, activeTraceId, activeSpanId, 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) => appendFifo(prev, partitioned.traces, STREAM_LIMITS.traces)) } if (partitioned.metrics.length) { setMetrics((prev) => appendFifo(prev, partitioned.metrics, STREAM_LIMITS.metrics)) } if (partitioned.logs.length) { setLogs((prev) => appendFifo(prev, partitioned.logs, STREAM_LIMITS.logs)) setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId)) } if (partitioned.traceIds.size) { setKnownTraceIds((prev) => addFifoSet(prev, partitioned.traceIds, STREAM_LIMITS.knownTraceIds)) } } }, []) 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.slice(-STREAM_LIMITS.traces)) setMetrics(partitioned.metrics.slice(-STREAM_LIMITS.metrics)) setLogs(partitioned.logs.slice(-STREAM_LIMITS.logs)) setLogsByTraceId(mergeLogsByTraceId({}, partitioned.logsByTraceId)) setKnownTraceIds(addFifoSet(new Set(), partitioned.traceIds, STREAM_LIMITS.knownTraceIds)) } 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 = appendFifo( bufferedMessagesRef.current, [data as OtelMessage], STREAM_LIMITS.bufferedMessages, ) setBufferedCount(bufferedMessagesRef.current.length) return } const partitioned = partitionMessages([data as OtelMessage]) if (partitioned.traces.length) { setTraces((prev) => appendFifo(prev, partitioned.traces, STREAM_LIMITS.traces)) } if (partitioned.metrics.length) { setMetrics((prev) => appendFifo(prev, partitioned.metrics, STREAM_LIMITS.metrics)) } if (partitioned.logs.length) { setLogs((prev) => appendFifo(prev, partitioned.logs, STREAM_LIMITS.logs)) setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId)) } if (partitioned.traceIds.size) { setKnownTraceIds((prev) => addFifoSet(prev, partitioned.traceIds, STREAM_LIMITS.knownTraceIds)) } } 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) => appendFifo(prev, partitioned.traces, STREAM_LIMITS.traces)) } if (partitioned.metrics.length) { setMetrics((prev) => appendFifo(prev, partitioned.metrics, STREAM_LIMITS.metrics)) } if (partitioned.logs.length) { setLogs((prev) => appendFifo(prev, partitioned.logs, STREAM_LIMITS.logs)) setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId)) } setKnownTraceIds((prev) => { return addFifoSet( prev, new Set([...partitioned.traceIds, activeTraceId]), STREAM_LIMITS.knownTraceIds, ) }) }) .catch((error) => { console.error(`Error fetching trace ${activeTraceId}:`, error) }) return () => { cancelled = true } }, [activeTraceId, knownTraceIds]) return ( {children} ) }