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' };