From 6a4a25937a214c131f9470ba72058e7735db1b90 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 18 Oct 2026 20:08:33 -0700 Subject: [PATCH] Rewrite live tracing, the trace timeline and the trace detail view The formatting rules (ids in the server's base 32, durations, offsets, event icons, the trace summary) are in format.ts, and the live stream's token, reconnects and event buffer in live.ts, all with tests; the normalizer and types are rewritten with a test too. The three components are rewritten on top, with the same look. One fix: stopping live tracing while a reconnect was pending could reopen the stream two seconds later. Stopping now stops it for good. --- .../tracing/components/LiveTracingPage.tsx | 497 ++++++++---------- .../tracing/components/TraceDetailView.tsx | 194 +++---- .../tracing/components/TraceTimeline.tsx | 383 +++++--------- src/features/tracing/format.test.ts | 96 ++++ src/features/tracing/format.ts | 66 +++ src/features/tracing/live.test.ts | 166 ++++++ src/features/tracing/live.ts | 120 +++++ src/features/tracing/normalize.test.ts | 79 +++ src/features/tracing/normalize.ts | 50 +- src/features/tracing/types.ts | 9 +- 10 files changed, 974 insertions(+), 686 deletions(-) create mode 100644 src/features/tracing/format.test.ts create mode 100644 src/features/tracing/format.ts create mode 100644 src/features/tracing/live.test.ts create mode 100644 src/features/tracing/live.ts create mode 100644 src/features/tracing/normalize.test.ts diff --git a/src/features/tracing/components/LiveTracingPage.tsx b/src/features/tracing/components/LiveTracingPage.tsx index 8830881..b56f3f2 100644 --- a/src/features/tracing/components/LiveTracingPage.tsx +++ b/src/features/tracing/components/LiveTracingPage.tsx @@ -1,311 +1,105 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Play, Square, RotateCcw, Plus, Check, X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; +import { Check, Play, Plus, RotateCcw, Square, X } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { useSchemaStore } from '@/stores/schemaStore'; -import { apiFetch, getApiBaseUrl } from '@/services/api'; -import type { TraceEvent } from '../types'; -import { normalizeTraceEvents } from '../normalize'; -import { TraceTimeline } from './TraceTimeline'; import { liveEventSubject } from '@/features/ai/explain/explain'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { appendEvents, liveTracingQuery, MAX_EVENTS, streamLiveTrace, type KeyFilter, type LiveBuffer } from '../live'; +import { TraceTimeline } from './TraceTimeline'; -const MAX_EVENTS = 1000; -const MAX_RECONNECT = 5; -const RECONNECT_DELAY = 2000; - -interface KeyValueFilter { - key: string; - label: string; - value: string; -} - -type LiveTracingState = +type Live = | { kind: 'idle' } - | { kind: 'starting'; keywords: string; filters: KeyValueFilter[] } - | { kind: 'streaming'; events: TraceEvent[]; anchorTimestamp: string; eventSource: EventSource } - | { kind: 'stopped'; events: TraceEvent[]; anchorTimestamp: string } - | { kind: 'error'; events: TraceEvent[]; error: string }; + | { kind: 'starting' } + | { kind: 'streaming'; buffer: LiveBuffer } + | { kind: 'stopped'; buffer: LiveBuffer } + /** Failed; the events so far stay on screen, timed from the first of them. */ + | { kind: 'failed'; buffer: LiveBuffer; error: string }; + +const EMPTY: LiveBuffer = { events: [], anchor: '' }; + +/** One live stream at a time; it stops when the page goes away. */ +function useLiveTrace() { + const { t } = useTranslation(); + const [live, setLive] = useState({ kind: 'idle' }); + const stopStream = useRef<(() => void) | null>(null); + + const halt = useCallback(() => { + stopStream.current?.(); + stopStream.current = null; + }, []); + useEffect(() => halt, [halt]); + + const start = useCallback( + (keywords: string, filters: KeyFilter[]) => { + halt(); + setLive({ kind: 'starting' }); + const bufferOf = (l: Live) => (l.kind === 'idle' || l.kind === 'starting' ? EMPTY : l.buffer); + stopStream.current = streamLiveTrace(liveTracingQuery(keywords, filters), { + opened: () => setLive((l) => ({ kind: 'streaming', buffer: bufferOf(l) })), + events: (batch) => setLive((l) => ({ kind: 'streaming', buffer: appendEvents(bufferOf(l), batch) })), + failed: (f) => + setLive((l) => ({ + kind: 'failed', + buffer: { events: bufferOf(l).events, anchor: '' }, + error: + f.reason === 'disconnected' + ? t('tracing.liveDisconnected', 'Live tracing stream disconnected after multiple retries.') + : (f.message ?? t('tracing.liveFailedStart', 'Failed to start live tracing.')), + })), + }); + }, + [halt, t], + ); + + const stop = useCallback(() => { + halt(); + setLive((l) => (l.kind === 'streaming' ? { kind: 'stopped', buffer: l.buffer } : l)); + }, [halt]); + + const reset = useCallback(() => { + halt(); + setLive({ kind: 'idle' }); + }, [halt]); + + return { live, start, stop, reset }; +} export function LiveTracingPage() { const { t } = useTranslation(); - const schema = useSchemaStore((s) => s.schema); - const [state, setState] = useState({ kind: 'idle' }); + const { live, start, stop, reset } = useLiveTrace(); const [keywords, setKeywords] = useState(''); - const [filters, setFilters] = useState([]); - const [addingFilter, setAddingFilter] = useState(false); - const [newFilterKey, setNewFilterKey] = useState(''); - const [newFilterValue, setNewFilterValue] = useState(''); - const eventSourceRef = useRef(null); - const eventsRef = useRef([]); - const anchorRef = useRef(''); - const reconnectAttempts = useRef(0); + const [filters, setFilters] = useState([]); - const keyEnum = useMemo(() => schema?.enums?.['Key'] ?? [], [schema]); - - useEffect(() => { - return () => { - if (eventSourceRef.current) { - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - }; - }, []); - - const openStream = useCallback(async () => { - setState({ kind: 'starting', keywords, filters }); - eventsRef.current = []; - anchorRef.current = ''; - reconnectAttempts.current = 0; - - try { - const tokenResponse = await apiFetch('/api/token/tracing'); - const token = await tokenResponse.text(); - - const params = new URLSearchParams(); - params.set('token', token); - if (keywords.trim()) { - params.set('filter', keywords.trim()); - } - for (const f of filters) { - params.set(f.key, f.value); - } - - const baseUrl = getApiBaseUrl(); - const url = `${baseUrl}/api/live/tracing?${params.toString()}`; - - const connectEventSource = () => { - const es = new EventSource(url); - eventSourceRef.current = es; - - es.addEventListener('trace', (event) => { - try { - const batch = normalizeTraceEvents(JSON.parse(event.data)); - const currentEvents = eventsRef.current; - - for (const evt of batch) { - currentEvents.push(evt); - if (!anchorRef.current) { - anchorRef.current = evt.timestamp; - } - } - - if (currentEvents.length > MAX_EVENTS) { - eventsRef.current = currentEvents.slice(currentEvents.length - MAX_EVENTS); - } - - setState({ - kind: 'streaming', - events: [...eventsRef.current], - anchorTimestamp: anchorRef.current, - eventSource: es, - }); - } catch (e) { - console.error('Failed to parse trace event:', e); - } - }); - - es.onopen = () => { - reconnectAttempts.current = 0; - setState({ - kind: 'streaming', - events: [...eventsRef.current], - anchorTimestamp: anchorRef.current || '', - eventSource: es, - }); - }; - - es.onerror = () => { - if (es !== eventSourceRef.current) return; - es.close(); - eventSourceRef.current = null; - - if (reconnectAttempts.current < MAX_RECONNECT) { - reconnectAttempts.current++; - setTimeout(connectEventSource, RECONNECT_DELAY); - } else { - setState({ - kind: 'error', - events: [...eventsRef.current], - error: t('tracing.liveDisconnected', 'Live tracing stream disconnected after multiple retries.'), - }); - } - }; - }; - - connectEventSource(); - } catch (e) { - setState({ - kind: 'error', - events: [], - error: e instanceof Error ? e.message : t('tracing.liveFailedStart', 'Failed to start live tracing.'), - }); - } - }, [keywords, filters, t]); - - const stopStream = useCallback(() => { - if (eventSourceRef.current) { - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - setState((prev) => { - if (prev.kind === 'streaming') { - return { - kind: 'stopped', - events: prev.events, - anchorTimestamp: prev.anchorTimestamp, - }; - } - return prev; - }); - }, []); - - const resetToIdle = useCallback(() => { - if (eventSourceRef.current) { - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - eventsRef.current = []; - anchorRef.current = ''; - setState({ kind: 'idle' }); - }, []); - - const addFilter = useCallback(() => { - if (!newFilterKey || !newFilterValue.trim()) return; - const keyEntry = keyEnum.find((e) => e.name === newFilterKey); - setFilters((prev) => [ - ...prev, - { - key: newFilterKey, - label: keyEntry?.label ?? newFilterKey, - value: newFilterValue.trim(), - }, - ]); - setNewFilterKey(''); - setNewFilterValue(''); - setAddingFilter(false); - }, [newFilterKey, newFilterValue, keyEnum]); - - const removeFilter = useCallback((idx: number) => { - setFilters((prev) => prev.filter((_, i) => i !== idx)); - }, []); - - const isIdle = state.kind === 'idle'; - const isStarting = state.kind === 'starting'; - const isStreaming = state.kind === 'streaming'; - const isStopped = state.kind === 'stopped'; - const isError = state.kind === 'error'; - - if (isIdle || isStarting) { + if (live.kind === 'idle' || live.kind === 'starting') { return ( -
-
-

{t('tracing.liveTitle', 'Live Tracing')}

-

- {t('tracing.liveSubtitle', 'Stream server events in real time.')} -

-
- - - -
- - setKeywords(e.target.value)} - /> -
- - {filters.length > 0 && ( -
- {filters.map((f, i) => ( - - {f.label} = {f.value} - - - ))} -
- )} - - {addingFilter ? ( -
- - setNewFilterValue(e.target.value)} - className="flex-1" - onKeyDown={(e) => { - if (e.key === 'Enter') addFilter(); - }} - /> - - -
- ) : ( - - )} - - -
-
-
+ start(keywords, filters)} + /> ); } - const events = isStreaming ? state.events : isStopped ? state.events : isError ? state.events : []; - const anchor = isStreaming ? state.anchorTimestamp : isStopped ? state.anchorTimestamp : undefined; - + const { events, anchor } = live.buffer; return (

{t('tracing.liveTitle', 'Live Tracing')}

- {isStreaming && ( + {live.kind === 'streaming' && (
@@ -314,17 +108,18 @@ export function LiveTracingPage() { {t('tracing.streamingEvents', 'Streaming events...')}
)} - {isStopped && {t('tracing.stopped', 'Stopped')}} + {live.kind === 'stopped' && ( + {t('tracing.stopped', 'Stopped')} + )}
- {isStreaming && ( - - )} - {(isStopped || isError) && ( - @@ -332,15 +127,19 @@ export function LiveTracingPage() {
- {isError && ( + {live.kind === 'failed' && (
- {state.error} + {live.error}
)} - liveEventSubject(event)} /> + liveEventSubject(event)} + /> @@ -353,3 +152,117 @@ export function LiveTracingPage() {
); } + +interface TracingFormProps { + keywords: string; + onKeywords: (keywords: string) => void; + filters: KeyFilter[]; + onFilters: (filters: KeyFilter[]) => void; + starting: boolean; + onStart: () => void; +} + +/** Keywords and key = value filters for the stream, then Start. */ +function TracingForm({ keywords, onKeywords, filters, onFilters, starting, onStart }: TracingFormProps) { + const { t } = useTranslation(); + const schema = useSchemaStore((s) => s.schema); + const keys = useMemo(() => schema?.enums?.Key ?? [], [schema]); + const [adding, setAdding] = useState(false); + const [key, setKey] = useState(''); + const [value, setValue] = useState(''); + + const add = () => { + if (!key || !value.trim()) return; + onFilters([...filters, { key, label: keys.find((k) => k.name === key)?.label ?? key, value: value.trim() }]); + setKey(''); + setValue(''); + setAdding(false); + }; + + return ( +
+
+

{t('tracing.liveTitle', 'Live Tracing')}

+

{t('tracing.liveSubtitle', 'Stream server events in real time.')}

+
+ + + +
+ + onKeywords(e.target.value)} + /> +
+ + {filters.length > 0 && ( +
+ {filters.map((f, i) => ( + + {f.label} = {f.value} + + + ))} +
+ )} + + {adding ? ( +
+ + setValue(e.target.value)} + className="flex-1" + onKeyDown={(e) => e.key === 'Enter' && add()} + /> + + +
+ ) : ( + + )} + + +
+
+
+ ); +} diff --git a/src/features/tracing/components/TraceDetailView.tsx b/src/features/tracing/components/TraceDetailView.tsx index 954447d..9786aa6 100644 --- a/src/features/tracing/components/TraceDetailView.tsx +++ b/src/features/tracing/components/TraceDetailView.tsx @@ -1,76 +1,61 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useEffect, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { ArrowLeft, FileText, Clock, Timer, Activity, Loader2 } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { Activity, ArrowLeft, Clock, FileText, Loader2, Timer } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { useSchemaStore } from '@/stores/schemaStore'; -import { jmapGet, getAccountId } from '@/services/jmap/client'; import { resolveObject } from '@/lib/schemaResolver'; -import type { TraceEvent } from '../types'; +import { getAccountId, jmapGet } from '@/services/jmap/client'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { longDuration, traceSummary } from '../format'; import { normalizeTraceEvents } from '../normalize'; +import type { TraceEvent } from '../types'; import { TraceTimeline } from './TraceTimeline'; +type Loaded = { kind: 'loading' } | { kind: 'failed'; error: string } | { kind: 'loaded'; events: TraceEvent[] }; -interface TraceDetailViewProps { - viewName: string; - objectId: string; -} - -export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) { +/** A stored trace (`x:Trace`): a summary of the span, then its events. */ +export function TraceDetailView({ viewName, objectId }: { viewName: string; objectId: string }) { const { t } = useTranslation(); const navigate = useNavigate(); const schema = useSchemaStore((s) => s.schema); - const [events, setEvents] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [loaded, setLoaded] = useState({ kind: 'loading' }); useEffect(() => { if (!schema) return; - let cancelled = false; - - async function load() { + let current = true; + void (async () => { try { - const resolved = resolveObject(schema!, viewName); - if (!resolved) throw new Error(t('view.couldNotResolve', 'Could not resolve object')); - - const accountId = getAccountId(resolved.objectName); - const responses = await jmapGet(resolved.objectName, accountId, [objectId]); - - if (cancelled) return; - - const getResult = responses.find(([name]) => name.endsWith('/get')); - if (!getResult) throw new Error(t('view.noGetResponse', 'No get response')); - - const data = getResult[1] as { list?: Array<{ id: string; events?: unknown }> }; - const trace = data.list?.[0]; + const object = resolveObject(schema, viewName); + if (!object) throw new Error(t('view.couldNotResolve', 'Could not resolve object')); + const responses = await jmapGet(object.objectName, getAccountId(object.objectName), [objectId]); + if (!current) return; + const got = responses.find(([name]) => name.endsWith('/get')); + if (!got) throw new Error(t('view.noGetResponse', 'No get response')); + const trace = (got[1].list as { events?: unknown }[] | undefined)?.[0]; if (!trace) throw new Error(t('tracing.traceNotFound', 'Trace not found')); - - setEvents(normalizeTraceEvents(trace.events)); + setLoaded({ kind: 'loaded', events: normalizeTraceEvents(trace.events) }); } catch (e) { - if (!cancelled) { - setError(e instanceof Error ? e.message : t('tracing.failedToLoadTrace', 'Failed to load trace')); + if (current) { + setLoaded({ + kind: 'failed', + error: e instanceof Error ? e.message : t('tracing.failedToLoadTrace', 'Failed to load trace'), + }); } - } finally { - if (!cancelled) setLoading(false); } - } - - load(); + })(); return () => { - cancelled = true; + current = false; }; }, [schema, viewName, objectId, t]); - if (loading) { + if (loaded.kind === 'loading') { return (
@@ -78,47 +63,21 @@ export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) { ); } - if (error) { + if (loaded.kind === 'failed') { return (
-
{error}
+
{loaded.error}
); } - if (!events) return null; - - const eventTypeEnum = schema?.enums?.['EventType'] ?? []; - const firstEvent = events[0]; - const lastEvent = events[events.length - 1]; - - const spanLabel = firstEvent - ? (eventTypeEnum.find((e) => e.name === firstEvent.event)?.label ?? firstEvent.event) - : '-'; - - const dateStr = firstEvent - ? new Intl.DateTimeFormat(undefined, { - dateStyle: 'medium', - timeStyle: 'medium', - }).format(new Date(firstEvent.timestamp)) - : '-'; - - const durationMs = - firstEvent && lastEvent && events.length >= 2 - ? new Date(lastEvent.timestamp).getTime() - new Date(firstEvent.timestamp).getTime() - : null; - - function formatDuration(ms: number): string { - if (ms < 1000) return `${Math.round(ms)} ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`; - const m = Math.floor(ms / 60_000); - const s = Math.floor((ms % 60_000) / 1000); - return `${m}m ${s}s`; - } + const { events } = loaded; + const eventNames = new Map((schema?.enums?.EventType ?? []).map((e) => [e.name, e.label])); + const summary = traceSummary(events, (event) => eventNames.get(event)); return (
@@ -128,52 +87,29 @@ export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
- - -
- - {t('tracing.spanType', 'Span Type')} -
-

{spanLabel}

-
-
- - - -
- - {t('tracing.date', 'Date')} -
-

{dateStr}

-
-
- - - -
- - {t('tracing.duration', 'Duration')} -
-

{durationMs !== null ? formatDuration(durationMs) : '-'}

-
-
- - - -
- - {t('tracing.events', 'Events')} -
-

{events.length}

-
-
+ } name={t('tracing.spanType', 'Span Type')} truncate> + {summary.span ?? '-'} + + } name={t('tracing.date', 'Date')}> + {summary.startedAt + ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format( + new Date(summary.startedAt), + ) + : '-'} + + } name={t('tracing.duration', 'Duration')}> + {summary.durationMs !== null ? longDuration(summary.durationMs) : '-'} + + } name={t('tracing.events', 'Events')}> + {events.length} +
({ '@type': 'TraceEvent', traceId: objectId, index })} /> @@ -181,3 +117,27 @@ export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
); } + +function Fact({ + icon, + name, + truncate, + children, +}: { + icon: ReactNode; + name: string; + truncate?: boolean; + children: ReactNode; +}) { + return ( + + +
+ {icon} + {name} +
+

{children}

+
+
+ ); +} diff --git a/src/features/tracing/components/TraceTimeline.tsx b/src/features/tracing/components/TraceTimeline.tsx index 0f45a1c..1ec6290 100644 --- a/src/features/tracing/components/TraceTimeline.tsx +++ b/src/features/tracing/components/TraceTimeline.tsx @@ -1,69 +1,91 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { CircleDot, CircleX, Clock } from 'lucide-react'; -import { useSchemaStore } from '@/stores/schemaStore'; -import type { TraceEvent, TraceKeyValue, TraceValue } from '../types'; import { ExplainButton } from '@/features/ai/explain/ExplainButton'; import { isRawEvent, type ExplainSubject } from '@/features/ai/explain/explain'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { eventTone, idToText, isIdKey, offsetFrom, shortDuration } from '../format'; +import type { TraceEvent, TraceKeyValue, TraceValue } from '../types'; -const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013'; +/** How close to the bottom counts as "at the bottom", so new events keep it scrolled there. */ +const STICK_PX = 50; -function intToBase32(input: number | string): string { - let n: bigint; - try { - n = BigInt(typeof input === 'number' ? Math.floor(input) : input); - } catch { - return String(input); - } - if (n === 0n) return 'a'; - const chars: string[] = []; - while (n > 0n) { - chars.push(BASE32_ALPHABET[Number(n % 32n)]); - n = n / 32n; - } - return chars.reverse().join(''); +interface EventName { + label: string; + explanation?: string; + /** False for an event type the schema doesn't name; it shows as its raw name. */ + known: boolean; } -function isIdKey(key: string): boolean { - return key === 'id' || key.endsWith('Id'); +/** People's names for event types and keys, from the schema's EventType and Key enums. */ +interface Names { + event: (type: string) => EventName; + key: (key: string) => string; + /** The section the queued-message pages live in, for queue id links. */ + queueSection: string; } +function useNames(): Names { + const schema = useSchemaStore((s) => s.schema); + const viewToSection = useSchemaStore((s) => s.viewToSection); + return useMemo(() => { + const events = new Map((schema?.enums?.EventType ?? []).map((e) => [e.name, e])); + const keys = new Map((schema?.enums?.Key ?? []).map((e) => [e.name, e.label])); + return { + event: (type) => { + const entry = events.get(type); + return entry + ? { label: entry.label, explanation: entry.explanation, known: true } + : { label: type, known: false }; + }, + key: (key) => keys.get(key) ?? key, + queueSection: viewToSection['x:QueuedMessage'] ?? 'Management', + }; + }, [schema, viewToSection]); +} + +const TONE_ICON = { + error: , + span: , + plain: , +}; + +const clockTime = (timestamp: string) => + new Date(timestamp).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + }); + interface TraceTimelineProps { events: TraceEvent[]; + /** What the offsets count from; the first event's time when absent. */ anchorTimestamp?: string; - /** inbuxa: what "Explain this" asks about an event (ai-explain EX-17). */ + /** What "Explain this" asks about an event (ai-explain EX-17); no button without it. */ explainSubject?: (event: TraceEvent, index: number) => ExplainSubject; } +/** A trace's events in order, each with its time, its offset from the anchor and its values. */ export function TraceTimeline({ events, anchorTimestamp, explainSubject }: TraceTimelineProps) { const { t } = useTranslation(); - const schema = useSchemaStore((s) => s.schema); - const containerRef = useRef(null); - const wasAtBottomRef = useRef(true); + const names = useNames(); + const scroller = useRef(null); + const atBottom = useRef(true); + // Follow new events, unless the reader has scrolled up to look at older ones. useEffect(() => { - const el = containerRef.current; - if (!el) return; - if (wasAtBottomRef.current) { - el.scrollTop = el.scrollHeight; - } + const el = scroller.current; + if (el && atBottom.current) el.scrollTop = el.scrollHeight; }, [events.length]); - const handleScroll = () => { - const el = containerRef.current; - if (!el) return; - wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50; - }; - if (events.length === 0) { return (
@@ -72,106 +94,54 @@ export function TraceTimeline({ events, anchorTimestamp, explainSubject }: Trace ); } - const anchorMs = anchorTimestamp ? new Date(anchorTimestamp).getTime() : new Date(events[0].timestamp).getTime(); - - const eventTypeEnum = schema?.enums?.['EventType'] ?? []; - const keyEnum = schema?.enums?.['Key'] ?? []; - - function resolveEventLabel(eventType: string): { label: string; explanation?: string } { - const entry = eventTypeEnum.find((e) => e.name === eventType); - if (entry) return { label: entry.label, explanation: entry.explanation }; - return { label: eventType }; - } - - function resolveKeyLabel(key: string): string { - const entry = keyEnum.find((e) => e.name === key); - return entry?.label ?? key; - } - - function eventIcon(eventType: string) { - if ( - eventType.includes('error') || - eventType.includes('failed') || - eventType.includes('invalid') || - eventType.includes('reject') - ) { - return ; - } - if (eventType.endsWith('-start') || eventType.endsWith('-end')) { - return ; - } - return ; - } - - function formatAbsoluteTime(ts: string): string { - const d = new Date(ts); - return d.toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - fractionalSecondDigits: 3, - } as Intl.DateTimeFormatOptions); - } - - function formatRelativeTime(ts: string): string { - const ms = new Date(ts).getTime() - anchorMs; - if (ms <= 0) return '(+0 ms)'; - if (ms < 1000) return `(+${Math.round(ms)} ms)`; - if (ms < 60_000) return `(+${(ms / 1000).toFixed(1)} s)`; - const m = Math.floor(ms / 60_000); - const s = Math.floor((ms % 60_000) / 1000); - return `(+${m} m ${s} s)`; - } + const anchorMs = Date.parse(anchorTimestamp || events[0].timestamp); return (
-
+
{ + const el = e.currentTarget; + atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_PX; + }} + className="max-h-[70vh] overflow-y-auto scroll-smooth" + >
- {events.map((event, idx) => { - const { label, explanation } = resolveEventLabel(event.event); - const isFallback = !eventTypeEnum.find((e) => e.name === event.event); - + {events.map((event, index) => { + const name = names.event(event.event); return ( -
-
{eventIcon(event.event)}
- +
+
{TONE_ICON[eventTone(event.event)]}
- {isFallback ? ( - {label} + {name.known ? ( + {name.label} ) : ( - {label} + {name.label} )} - {explanation &&

{explanation}

} + {name.explanation &&

{name.explanation}

}
- {/* inbuxa: raw protocol traffic isn't sent to the model (EX-9) */} + {/* Raw protocol traffic isn't sent to the model (EX-9). */} {explainSubject && !isRawEvent(event.event) && ( explainSubject(event, idx)} + title={name.label} + subject={() => explainSubject(event, index)} /> )}
-
{formatAbsoluteTime(event.timestamp)}
-
{formatRelativeTime(event.timestamp)}
+
{clockTime(event.timestamp)}
+
{offsetFrom(anchorMs, event.timestamp)}
- {event.keyValues.length > 0 && (
- {event.keyValues.map((kv, ki) => ( - + {event.keyValues.map((kv, i) => ( + ))}
)} @@ -186,148 +156,73 @@ export function TraceTimeline({ events, anchorTimestamp, explainSubject }: Trace ); } -function TlsBoolean({ on }: { on: boolean }) { - const { t } = useTranslation(); - return <>{on ? t('tracing.enabled', 'enabled') : t('tracing.disabled', 'disabled')}; +interface ValueProps { + names: Names; + /** How deep in nested events this is; past two levels, an event shows as JSON. */ + depth: number; } -function TraceKeyValueRow({ - kv, - resolveKeyLabel, - resolveEventLabel, - depth, -}: { - kv: TraceKeyValue; - resolveKeyLabel: (key: string) => string; - resolveEventLabel: (event: string) => { label: string; explanation?: string }; - depth: number; -}) { - const label = resolveKeyLabel(kv.key); - const viewToSection = useSchemaStore((s) => s.viewToSection); - +function ValueRow({ kv, names, depth }: ValueProps & { kv: TraceKeyValue }) { return (
- {label} + {names.key(kv.key)} - +
); } -function TraceValueDisplay({ - keyName, - value, - resolveKeyLabel, - resolveEventLabel, - viewToSection, - depth, -}: { - keyName: string; - value: TraceValue; - resolveKeyLabel: (key: string) => string; - resolveEventLabel: (event: string) => { label: string; explanation?: string }; - viewToSection: Record; - depth: number; -}) { +/** Very large integers can arrive wrapped as `{ source: "" }`. */ +function integerOf(raw: unknown): number | string { + return raw !== null && typeof raw === 'object' && 'source' in raw ? String(raw.source) : (raw as number); +} + +function Value({ keyName, value, names, depth }: ValueProps & { keyName: string; value: TraceValue }): ReactNode { + const { t } = useTranslation(); switch (value['@type']) { case 'String': - return <>{value.value}; + case 'IpAddr': + return value.value; case 'UnsignedInt': case 'Integer': { - const raw = value.value; - const numericInput = - typeof raw === 'object' && raw !== null && 'source' in (raw as Record) - ? ((raw as Record).source as string) - : raw; - - if (isIdKey(keyName)) { - const encoded = intToBase32(numericInput as number | string); - if (keyName === 'queueId') { - const section = viewToSection['x:QueuedMessage'] ?? 'Management'; - return ( - - {encoded} - - ); - } - return {encoded}; - } - - const displayNum = typeof numericInput === 'number' ? numericInput : Number(numericInput); - return <>{displayNum.toLocaleString()}; - } - case 'Boolean': { - if (keyName === 'tls') { - return ; - } - return <>{String(value.value)}; + const n = integerOf(value.value); + if (!isIdKey(keyName)) return Number(n).toLocaleString(); + const id = idToText(n); + if (keyName !== 'queueId') return {id}; + return ( + + {id} + + ); } + case 'Boolean': + if (keyName !== 'tls') return String(value.value); + return value.value ? t('tracing.enabled', 'enabled') : t('tracing.disabled', 'disabled'); case 'Float': - return <>{value.value.toFixed(2)}; + return value.value.toFixed(2); case 'UTCDateTime': - return ( - <> - {new Intl.DateTimeFormat(undefined, { - dateStyle: 'medium', - timeStyle: 'medium', - }).format(new Date(value.value))} - + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format( + new Date(value.value), ); - case 'Duration': { - const ms = value.value; - if (ms < 1000) return <>{Math.round(ms)} ms; - if (ms < 60_000) return <>{(ms / 1000).toFixed(1)} s; - const m = Math.floor(ms / 60_000); - const s = Math.floor((ms % 60_000) / 1000); - return ( - <> - {m} m {s} s - - ); - } - case 'IpAddr': - return <>{value.value}; + case 'Duration': + return shortDuration(value.value); case 'List': { - if (value.value.length <= 3) { - return ( - <> - {value.value.map((v, i) => ( - - {i > 0 && ', '} - - - ))} - - ); - } + const items = value.value.map((item, i) => ( + + )); + // Up to three on one line, more one per line. + if (items.length <= 3) + return items.map((item, i) => ( + + {i > 0 && ', '} + {item} + + )); return (
- {value.value.map((v, i) => ( -
- -
+ {items.map((item, i) => ( +
{item}
))}
); @@ -340,19 +235,13 @@ function TraceValueDisplay({ ); } - const { label, explanation } = resolveEventLabel(value.event); + const name = names.event(value.event); return (
-
{label}
- {explanation &&
{explanation}
} +
{name.label}
+ {name.explanation &&
{name.explanation}
} {value.value.map((kv, i) => ( - + ))}
); @@ -360,6 +249,6 @@ function TraceValueDisplay({ case 'Null': return —; default: - return <>{JSON.stringify(value)}; + return JSON.stringify(value); } } diff --git a/src/features/tracing/format.test.ts b/src/features/tracing/format.test.ts new file mode 100644 index 0000000..0a63581 --- /dev/null +++ b/src/features/tracing/format.test.ts @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; +import { eventTone, idToText, isIdKey, longDuration, offsetFrom, shortDuration, traceSummary } from './format'; +import type { TraceEvent } from './types'; + +describe('idToText', () => { + it("spells ids in the server's base-32 alphabet", () => { + expect(idToText(0)).toBe('a'); + expect(idToText(31)).toBe('3'); + expect(idToText(32)).toBe('ba'); + expect(idToText(1234567)).toBe('bfvuh'); + expect(idToText(42.9)).toBe('bk'); + }); + + it('takes ids too big for a number as decimal text', () => { + expect(idToText('18446744073709551615')).toBe('p333333333333'); + }); + + it('shows anything that is not a whole number as it came', () => { + expect(idToText('not a number')).toBe('not a number'); + }); +}); + +describe('isIdKey', () => { + it('knows ids by name', () => { + expect(isIdKey('id')).toBe(true); + expect(isIdKey('queueId')).toBe(true); + expect(isIdKey('identity')).toBe(false); + expect(isIdKey('Id')).toBe(true); + }); +}); + +describe('eventTone', () => { + it.each([ + ['smtp.error', 'error'], + ['auth.failed', 'error'], + ['message.invalid-header', 'error'], + ['spam.rejected', 'error'], + ['smtp.connection-start', 'span'], + ['delivery.attempt-end', 'span'], + ['smtp.ehlo', 'plain'], + ] as const)('%s is %s', (event, tone) => { + expect(eventTone(event)).toBe(tone); + }); +}); + +describe('durations', () => { + it('reads an offset from the first event', () => { + const at = '2026-10-18T10:00:00.000Z'; + const plus = (ms: number) => new Date(Date.parse(at) + ms).toISOString(); + expect(offsetFrom(Date.parse(at), at)).toBe('(+0 ms)'); + expect(offsetFrom(Date.parse(at), plus(-5))).toBe('(+0 ms)'); + expect(offsetFrom(Date.parse(at), plus(250))).toBe('(+250 ms)'); + expect(offsetFrom(Date.parse(at), plus(12_340))).toBe('(+12.3 s)'); + expect(offsetFrom(Date.parse(at), plus(125_000))).toBe('(+2 m 5 s)'); + }); + + it('spells durations two ways: spaced in a value, joined in a summary', () => { + expect(shortDuration(999.6)).toBe('1000 ms'); + expect(shortDuration(1500)).toBe('1.5 s'); + expect(shortDuration(61_000)).toBe('1 m 1 s'); + expect(longDuration(61_000)).toBe('1m 1s'); + expect(longDuration(800)).toBe('800 ms'); + }); +}); + +describe('traceSummary', () => { + const ev = (event: string, timestamp: string): TraceEvent => ({ event, timestamp, keyValues: [] }); + const label = (name: string) => (name === 'smtp.connection-start' ? 'SMTP connection started' : undefined); + + it('names the span by its first event and times it from first to last', () => { + expect( + traceSummary( + [ + ev('smtp.connection-start', '2026-10-18T10:00:00.000Z'), + ev('smtp.connection-end', '2026-10-18T10:00:02.500Z'), + ], + label, + ), + ).toEqual({ span: 'SMTP connection started', startedAt: '2026-10-18T10:00:00.000Z', durationMs: 2500 }); + }); + + it('has no duration for a single event, and nothing for none', () => { + expect(traceSummary([ev('odd.event', '2026-10-18T10:00:00.000Z')], label)).toEqual({ + span: 'odd.event', + startedAt: '2026-10-18T10:00:00.000Z', + durationMs: null, + }); + expect(traceSummary([], label)).toEqual({ span: null, startedAt: null, durationMs: null }); + }); +}); diff --git a/src/features/tracing/format.ts b/src/features/tracing/format.ts new file mode 100644 index 0000000..3233688 --- /dev/null +++ b/src/features/tracing/format.ts @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** How trace events and their values read on screen. */ +import type { TraceEvent } from './types'; + +/** The server writes ids in base 32 with this alphabet, so a queue id here matches the one in its logs and URLs. */ +const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013'; + +/** An id as the server spells it. Very large ids arrive as decimal text; anything else shows as it came. */ +export function idToText(id: number | string): string { + let n: bigint; + try { + n = BigInt(typeof id === 'number' ? Math.floor(id) : id); + } catch { + return String(id); + } + if (n < 0n) return String(id); + let text = ''; + do { + text = ID_ALPHABET[Number(n % 32n)] + text; + n /= 32n; + } while (n > 0n); + return text; +} + +/** Keys named `id` or ending in `Id` hold ids. */ +export const isIdKey = (key: string) => key === 'id' || key.endsWith('Id'); + +/** Failures get the error icon, span boundaries the clock, the rest a dot. */ +export function eventTone(event: string): 'error' | 'span' | 'plain' { + if (['error', 'failed', 'invalid', 'reject'].some((word) => event.includes(word))) return 'error'; + if (event.endsWith('-start') || event.endsWith('-end')) return 'span'; + return 'plain'; +} + +/** A duration in a trace value: "250 ms", "1.5 s", "2 m 5 s". */ +export function shortDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)} ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`; + return `${Math.floor(ms / 60_000)} m ${Math.floor((ms % 60_000) / 1000)} s`; +} + +/** A trace's whole duration in its summary: minutes and seconds run together, "2m 5s". */ +export function longDuration(ms: number): string { + return ms < 60_000 ? shortDuration(ms) : `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`; +} + +/** How long after the anchor an event happened, "(+250 ms)"; never negative. */ +export function offsetFrom(anchorMs: number, timestamp: string): string { + return `(+${shortDuration(Math.max(0, Date.parse(timestamp) - anchorMs))})`; +} + +/** A stored trace in brief: what its first event was, when it started, and how long it ran. */ +export function traceSummary(events: TraceEvent[], label: (event: string) => string | undefined) { + const first = events.at(0); + const last = events.at(-1); + return { + span: first ? (label(first.event) ?? first.event) : null, + startedAt: first?.timestamp ?? null, + durationMs: first && last && events.length >= 2 ? Date.parse(last.timestamp) - Date.parse(first.timestamp) : null, + }; +} diff --git a/src/features/tracing/live.test.ts b/src/features/tracing/live.test.ts new file mode 100644 index 0000000..a39a3fc --- /dev/null +++ b/src/features/tracing/live.test.ts @@ -0,0 +1,166 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiFetch = vi.fn(); +vi.mock('@/services/api', () => ({ + apiFetch: (...args: unknown[]) => apiFetch(...args), + getApiBaseUrl: () => 'https://mail.example', +})); + +class FakeSource { + static all: FakeSource[] = []; + closed = false; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + private listeners: ((e: { data: string }) => void)[] = []; + url: string; + constructor(url: string) { + this.url = url; + FakeSource.all.push(this); + } + addEventListener(name: string, fn: (e: { data: string }) => void) { + if (name === 'trace') this.listeners.push(fn); + } + close() { + this.closed = true; + } + send(data: unknown) { + for (const fn of this.listeners) fn({ data: typeof data === 'string' ? data : JSON.stringify(data) }); + } +} +vi.stubGlobal('EventSource', FakeSource); + +const { appendEvents, liveTracingQuery, streamLiveTrace, MAX_EVENTS } = await import('./live'); + +const ev = (n: number) => ({ event: `e${n}`, timestamp: `t${n}`, keyValues: [] }); +const last = () => FakeSource.all.at(-1)!; +const handlers = () => ({ opened: vi.fn(), events: vi.fn(), failed: vi.fn() }); + +beforeEach(() => { + FakeSource.all = []; + apiFetch.mockReset().mockResolvedValue({ text: async () => 'tok' }); + vi.useFakeTimers(); +}); +afterEach(() => vi.useRealTimers()); + +describe('appendEvents', () => { + it('keeps the newest events up to the cap, and the first timestamp ever seen', () => { + let buffer = appendEvents({ events: [], anchor: '' }, [ev(1), ev(2)]); + expect(buffer).toEqual({ events: [ev(1), ev(2)], anchor: 't1' }); + buffer = appendEvents( + buffer, + Array.from({ length: MAX_EVENTS }, (_, i) => ev(i + 3)), + ); + expect(buffer.events).toHaveLength(MAX_EVENTS); + expect(buffer.events[0]).toEqual(ev(3)); + expect(buffer.anchor).toBe('t1'); + }); +}); + +describe('liveTracingQuery', () => { + it('sends the keywords and one parameter per filter, the last value winning', () => { + expect( + liveTracingQuery(' smtp ', [ + { key: 'remoteIp', label: 'Remote IP', value: '1' }, + { key: 'remoteIp', label: 'Remote IP', value: '2' }, + { key: 'domain', label: 'Domain', value: 'a.org' }, + ]), + ).toBe('filter=smtp&remoteIp=2&domain=a.org'); + expect(liveTracingQuery(' ', [])).toBe(''); + }); +}); + +describe('streamLiveTrace', () => { + it('opens the stream with a fresh token and passes on each batch', async () => { + const on = handlers(); + streamLiveTrace('filter=x', on); + await vi.advanceTimersByTimeAsync(0); + expect(apiFetch).toHaveBeenCalledWith('/api/token/tracing'); + expect(last().url).toBe('https://mail.example/api/live/tracing?token=tok&filter=x'); + last().onopen!(); + expect(on.opened).toHaveBeenCalled(); + last().send({ + 0: { + event: 'smtp.ehlo', + timestamp: 't', + keyValues: { 0: { key: 'domain', value: { '@type': 'String', value: 'a' } } }, + }, + }); + expect(on.events).toHaveBeenCalledWith([ + { event: 'smtp.ehlo', timestamp: 't', keyValues: [{ key: 'domain', value: { '@type': 'String', value: 'a' } }] }, + ]); + }); + + it('skips a batch it cannot read', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const on = handlers(); + streamLiveTrace('', on); + await vi.advanceTimersByTimeAsync(0); + last().send('{oops'); + expect(on.events).not.toHaveBeenCalled(); + }); + + it('reconnects up to five times, two seconds apart, then gives up', async () => { + const on = handlers(); + streamLiveTrace('', on); + await vi.advanceTimersByTimeAsync(0); + for (let i = 1; i <= 5; i++) { + last().onerror!(); + expect(last().closed).toBe(true); + await vi.advanceTimersByTimeAsync(1999); + expect(FakeSource.all).toHaveLength(i); + await vi.advanceTimersByTimeAsync(1); + expect(FakeSource.all).toHaveLength(i + 1); + } + expect(apiFetch).toHaveBeenCalledTimes(1); + last().onerror!(); + expect(on.failed).toHaveBeenCalledWith({ reason: 'disconnected' }); + await vi.advanceTimersByTimeAsync(10_000); + expect(FakeSource.all).toHaveLength(6); + }); + + it('starts counting again after a reconnect opens', async () => { + const on = handlers(); + streamLiveTrace('', on); + await vi.advanceTimersByTimeAsync(0); + for (let i = 0; i < 4; i++) { + last().onerror!(); + await vi.advanceTimersByTimeAsync(2000); + } + last().onopen!(); + for (let i = 0; i < 5; i++) { + last().onerror!(); + await vi.advanceTimersByTimeAsync(2000); + } + expect(on.failed).not.toHaveBeenCalled(); + }); + + it('fails to start when there is no token', async () => { + apiFetch.mockRejectedValueOnce(new Error('HTTP 403')); + const on = handlers(); + streamLiveTrace('', on); + await vi.advanceTimersByTimeAsync(0); + expect(on.failed).toHaveBeenCalledWith({ reason: 'start', message: 'HTTP 403' }); + }); + + it('stays stopped: no reconnect, no events, nothing opened late', async () => { + const on = handlers(); + const stop = streamLiveTrace('', on); + await vi.advanceTimersByTimeAsync(0); + last().onerror!(); + stop(); + await vi.advanceTimersByTimeAsync(5000); + expect(FakeSource.all).toHaveLength(1); + + const early = streamLiveTrace('', on); + early(); + await vi.advanceTimersByTimeAsync(0); + expect(FakeSource.all).toHaveLength(1); + expect(on.failed).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/tracing/live.ts b/src/features/tracing/live.ts new file mode 100644 index 0000000..ba3b1a6 --- /dev/null +++ b/src/features/tracing/live.ts @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** Live tracing: the server's event stream, kept open through short drops, and the buffer the page shows. */ +import { apiFetch, getApiBaseUrl } from '@/services/api'; +import { normalizeTraceEvents } from './normalize'; +import type { TraceEvent } from './types'; + +/** The page keeps the newest events up to this many. */ +export const MAX_EVENTS = 1000; +/** A dropped stream is reopened this many times in a row, this far apart, before the page gives up. */ +const RETRIES = 5; +const RETRY_DELAY_MS = 2000; + +export interface LiveBuffer { + events: TraceEvent[]; + /** The first event's timestamp, which the offsets count from; it stays when old events are dropped. */ + anchor: string; +} + +export function appendEvents(buffer: LiveBuffer, batch: TraceEvent[]): LiveBuffer { + const events = [...buffer.events, ...batch]; + return { + events: events.length > MAX_EVENTS ? events.slice(-MAX_EVENTS) : events, + anchor: buffer.anchor || (batch[0]?.timestamp ?? ''), + }; +} + +/** A key = value filter on the stream; `label` is the key's name for people. */ +export interface KeyFilter { + key: string; + label: string; + value: string; +} + +/** The stream's query, less the token: any keywords, and each filter as its own parameter. */ +export function liveTracingQuery(keywords: string, filters: KeyFilter[]): string { + const query = new URLSearchParams(); + if (keywords.trim()) query.set('filter', keywords.trim()); + for (const { key, value } of filters) query.set(key, value); + return query.toString(); +} + +export type LiveFailure = { reason: 'disconnected' } | { reason: 'start'; message: string | null }; + +export interface LiveEvents { + opened: () => void; + events: (batch: TraceEvent[]) => void; + failed: (failure: LiveFailure) => void; +} + +/** + * Opens the live trace stream. `query` is everything but the token, which + * is fetched first, as EventSource can't send headers. Returns a function + * that stops it for good: no reconnects and no more callbacks after it. + */ +export function streamLiveTrace(query: string, on: LiveEvents): () => void { + let source: EventSource | null = null; + let retry: ReturnType | undefined; + let stopped = false; + let retries = 0; + + const stop = () => { + stopped = true; + clearTimeout(retry); + source?.close(); + source = null; + }; + + const connect = (url: string) => { + const es = new EventSource(url); + source = es; + es.onopen = () => { + if (stopped) return; + retries = 0; + on.opened(); + }; + es.addEventListener('trace', (message) => { + if (stopped) return; + let batch: TraceEvent[]; + try { + batch = normalizeTraceEvents(JSON.parse((message as MessageEvent).data)); + } catch (e) { + console.error('Failed to parse trace event:', e); + return; + } + on.events(batch); + }); + es.onerror = () => { + if (stopped || es !== source) return; + es.close(); + source = null; + if (retries < RETRIES) { + retries++; + retry = setTimeout(() => connect(url), RETRY_DELAY_MS); + } else { + stopped = true; + on.failed({ reason: 'disconnected' }); + } + }; + }; + + void (async () => { + let token: string; + try { + token = await (await apiFetch('/api/token/tracing')).text(); + } catch (e) { + if (!stopped) on.failed({ reason: 'start', message: e instanceof Error ? e.message : null }); + return; + } + if (stopped) return; + const rest = query ? `&${query}` : ''; + connect(`${getApiBaseUrl()}/api/live/tracing?token=${encodeURIComponent(token)}${rest}`); + })(); + + return stop; +} diff --git a/src/features/tracing/normalize.test.ts b/src/features/tracing/normalize.test.ts new file mode 100644 index 0000000..871e13e --- /dev/null +++ b/src/features/tracing/normalize.test.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; +import { normalizeTraceEvents } from './normalize'; + +describe('normalizeTraceEvents', () => { + it('turns JMAP maps into arrays, in index order, all the way down', () => { + expect( + normalizeTraceEvents({ + 1: { event: 'b', timestamp: 't2', keyValues: [] }, + 0: { + event: 'a', + timestamp: 't1', + keyValues: { + 1: { + key: 'list', + value: { + '@type': 'List', + value: { 1: { '@type': 'Integer', value: 2 }, 0: { '@type': 'Integer', value: 1 } }, + }, + }, + 0: { + key: 'cause', + value: { + '@type': 'Event', + event: 'inner', + value: { 0: { key: 'n', value: { '@type': 'Boolean', value: true } } }, + }, + }, + }, + }, + }), + ).toEqual([ + { + event: 'a', + timestamp: 't1', + keyValues: [ + { + key: 'cause', + value: { + '@type': 'Event', + event: 'inner', + value: [{ key: 'n', value: { '@type': 'Boolean', value: true } }], + }, + }, + { + key: 'list', + value: { + '@type': 'List', + value: [ + { '@type': 'Integer', value: 1 }, + { '@type': 'Integer', value: 2 }, + ], + }, + }, + ], + }, + { event: 'b', timestamp: 't2', keyValues: [] }, + ]); + }); + + it('fills in what is missing', () => { + expect(normalizeTraceEvents([{ keyValues: [{ value: 'not an object' }, {}] }])).toEqual([ + { + event: '', + timestamp: '', + keyValues: [ + { key: '', value: { '@type': 'Null' } }, + { key: '', value: { '@type': 'Null' } }, + ], + }, + ]); + expect(normalizeTraceEvents(null)).toEqual([]); + }); +}); diff --git a/src/features/tracing/normalize.ts b/src/features/tracing/normalize.ts index 2807b52..6bbdbb6 100644 --- a/src/features/tracing/normalize.ts +++ b/src/features/tracing/normalize.ts @@ -1,45 +1,39 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ import { jmapMapToArray } from '@/lib/jmapUtils'; import type { TraceEvent, TraceKeyValue, TraceValue } from './types'; +type Raw = Record; + /** - * Trace events as the server sends them, stored or live: key-values arrive as - * a JMAP map ({"0": …, "1": …}), not an array. + * Trace events as the server sends them, stored or live. Lists arrive as + * JMAP maps ({"0": …, "1": …}), at every level, and become arrays here. */ export function normalizeTraceEvents(raw: unknown): TraceEvent[] { - const events = jmapMapToArray>(raw); - return events.map((evt) => ({ - event: String(evt.event ?? ''), - timestamp: String(evt.timestamp ?? ''), - keyValues: normalizeKeyValues(evt.keyValues), + return jmapMapToArray(raw).map((event) => ({ + event: String(event.event ?? ''), + timestamp: String(event.timestamp ?? ''), + keyValues: keyValues(event.keyValues), })); } -function normalizeKeyValues(raw: unknown): TraceKeyValue[] { - const kvs = jmapMapToArray>(raw); - return kvs.map((kv) => ({ - key: String(kv.key ?? ''), - value: normalizeTraceValue(kv.value), - })); +function keyValues(raw: unknown): TraceKeyValue[] { + return jmapMapToArray(raw).map((kv) => ({ key: String(kv.key ?? ''), value: value(kv.value) })); } -function normalizeTraceValue(raw: unknown): TraceValue { - if (!raw || typeof raw !== 'object') return { '@type': 'Null' }; - const obj = raw as Record; - const type = obj['@type'] as string; - - if (type === 'List') { - return { '@type': 'List', value: jmapMapToArray(obj.value).map(normalizeTraceValue) }; +function value(raw: unknown): TraceValue { + if (raw === null || typeof raw !== 'object') return { '@type': 'Null' }; + const v = raw as Raw; + switch (v['@type']) { + case 'List': + return { '@type': 'List', value: jmapMapToArray(v.value).map(value) }; + case 'Event': + return { '@type': 'Event', event: String(v.event ?? ''), value: keyValues(v.value) }; + default: + return raw as TraceValue; } - if (type === 'Event') { - return { '@type': 'Event', event: String(obj.event ?? ''), value: normalizeKeyValues(obj.value) }; - } - return raw as TraceValue; } diff --git a/src/features/tracing/types.ts b/src/features/tracing/types.ts index d25d172..e25b8bb 100644 --- a/src/features/tracing/types.ts +++ b/src/features/tracing/types.ts @@ -1,24 +1,28 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ +/** A trace: the events of one span, such as an SMTP session, in order. */ export interface Trace { events: TraceEvent[]; } export interface TraceEvent { + /** The event type, e.g. `smtp.ehlo`; the schema's EventType enum names it for people. */ event: string; keyValues: TraceKeyValue[]; timestamp: string; } export interface TraceKeyValue { + /** The key; the schema's Key enum names it for people. */ key: string; value: TraceValue; } +/** A value, tagged with its kind as the server sends it. Durations are in milliseconds. */ export type TraceValue = | { '@type': 'String'; value: string } | { '@type': 'UnsignedInt'; value: number } @@ -29,5 +33,6 @@ export type TraceValue = | { '@type': 'Duration'; value: number } | { '@type': 'IpAddr'; value: string } | { '@type': 'List'; value: TraceValue[] } + /** Another event nested inside this one, such as the cause of an error. */ | { '@type': 'Event'; event: string; value: TraceKeyValue[] } | { '@type': 'Null' }; -- 2.54.0