Rewrite live tracing, the trace timeline and the trace detail view #129

Merged
jcoffey-dev merged 1 commits from phase4/tracing into main 2026-10-19 04:17:39 +00:00
10 changed files with 974 additions and 686 deletions

No files matched your search

@@ -1,311 +1,105 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<Live>({ 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<LiveTracingState>({ kind: 'idle' });
const { live, start, stop, reset } = useLiveTrace();
const [keywords, setKeywords] = useState('');
const [filters, setFilters] = useState<KeyValueFilter[]>([]);
const [addingFilter, setAddingFilter] = useState(false);
const [newFilterKey, setNewFilterKey] = useState('');
const [newFilterValue, setNewFilterValue] = useState('');
const eventSourceRef = useRef<EventSource | null>(null);
const eventsRef = useRef<TraceEvent[]>([]);
const anchorRef = useRef<string>('');
const reconnectAttempts = useRef(0);
const [filters, setFilters] = useState<KeyFilter[]>([]);
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 (
<div className="mx-auto max-w-2xl space-y-6 pt-8">
<div>
<h1 className="text-2xl font-bold">{t('tracing.liveTitle', 'Live Tracing')}</h1>
<p className="mt-2 text-muted-foreground">
{t('tracing.liveSubtitle', 'Stream server events in real time.')}
</p>
</div>
<Card>
<CardContent className="p-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('tracing.keywordsLabel', 'Keywords (optional)')}</label>
<Input
placeholder={t('tracing.keywordsPlaceholder', 'Enter keywords to filter events...')}
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
/>
</div>
{filters.length > 0 && (
<div className="flex flex-wrap gap-2">
{filters.map((f, i) => (
<Badge key={i} variant="secondary" className="gap-1">
{f.label} = {f.value}
<button onClick={() => removeFilter(i)} className="ml-1 hover:text-destructive">
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
{addingFilter ? (
<div className="flex items-center gap-2">
<Select value={newFilterKey} onValueChange={setNewFilterKey}>
<SelectTrigger className="w-48">
<SelectValue placeholder={t('field.selectKey', 'Select key...')} />
</SelectTrigger>
<SelectContent className="max-h-60">
{keyEnum.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder={t('tracing.valuePlaceholder', 'Value...')}
value={newFilterValue}
onChange={(e) => setNewFilterValue(e.target.value)}
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter') addFilter();
}}
/>
<Button size="icon" variant="ghost" onClick={addFilter}>
<Check className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" onClick={() => setAddingFilter(false)}>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<Button variant="outline" size="sm" onClick={() => setAddingFilter(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('tracing.addFilter', 'Add filter')}
</Button>
)}
<Button className="w-full" onClick={openStream} disabled={isStarting}>
{isStarting ? (
<>
<span className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t('tracing.connecting', 'Connecting...')}
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
{t('tracing.startTracing', 'Start tracing')}
</>
)}
</Button>
</CardContent>
</Card>
</div>
<TracingForm
keywords={keywords}
onKeywords={setKeywords}
filters={filters}
onFilters={setFilters}
starting={live.kind === 'starting'}
onStart={() => 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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">{t('tracing.liveTitle', 'Live Tracing')}</h2>
{isStreaming && (
{live.kind === 'streaming' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
@@ -314,17 +108,18 @@ export function LiveTracingPage() {
{t('tracing.streamingEvents', 'Streaming events...')}
</div>
)}
{isStopped && <span className="text-sm text-muted-foreground">{t('tracing.stopped', 'Stopped')}</span>}
{live.kind === 'stopped' && (
<span className="text-sm text-muted-foreground">{t('tracing.stopped', 'Stopped')}</span>
)}
</div>
<div className="flex gap-2">
{isStreaming && (
<Button variant="outline" onClick={stopStream}>
{live.kind === 'streaming' ? (
<Button variant="outline" onClick={stop}>
<Square className="mr-2 h-4 w-4" />
{t('tracing.stopTracing', 'Stop tracing')}
</Button>
)}
{(isStopped || isError) && (
<Button variant="outline" onClick={resetToIdle}>
) : (
<Button variant="outline" onClick={reset}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('tracing.restart', 'Restart')}
</Button>
@@ -332,15 +127,19 @@ export function LiveTracingPage() {
</div>
</div>
{isError && (
{live.kind === 'failed' && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.error}
{live.error}
</div>
)}
<Card>
<CardContent className="p-4">
<TraceTimeline events={events} anchorTimestamp={anchor} explainSubject={(event) => liveEventSubject(event)} />
<TraceTimeline
events={events}
anchorTimestamp={anchor || undefined}
explainSubject={(event) => liveEventSubject(event)}
/>
</CardContent>
</Card>
@@ -353,3 +152,117 @@ export function LiveTracingPage() {
</div>
);
}
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 (
<div className="mx-auto max-w-2xl space-y-6 pt-8">
<div>
<h1 className="text-2xl font-bold">{t('tracing.liveTitle', 'Live Tracing')}</h1>
<p className="mt-2 text-muted-foreground">{t('tracing.liveSubtitle', 'Stream server events in real time.')}</p>
</div>
<Card>
<CardContent className="p-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('tracing.keywordsLabel', 'Keywords (optional)')}</label>
<Input
placeholder={t('tracing.keywordsPlaceholder', 'Enter keywords to filter events...')}
value={keywords}
onChange={(e) => onKeywords(e.target.value)}
/>
</div>
{filters.length > 0 && (
<div className="flex flex-wrap gap-2">
{filters.map((f, i) => (
<Badge key={i} variant="secondary" className="gap-1">
{f.label} = {f.value}
<button
onClick={() => onFilters(filters.filter((_, j) => j !== i))}
className="ml-1 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
{adding ? (
<div className="flex items-center gap-2">
<Select value={key} onValueChange={setKey}>
<SelectTrigger className="w-48">
<SelectValue placeholder={t('field.selectKey', 'Select key...')} />
</SelectTrigger>
<SelectContent className="max-h-60">
{keys.map((k) => (
<SelectItem key={k.name} value={k.name}>
{k.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder={t('tracing.valuePlaceholder', 'Value...')}
value={value}
onChange={(e) => setValue(e.target.value)}
className="flex-1"
onKeyDown={(e) => e.key === 'Enter' && add()}
/>
<Button size="icon" variant="ghost" onClick={add}>
<Check className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" onClick={() => setAdding(false)}>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<Button variant="outline" size="sm" onClick={() => setAdding(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('tracing.addFilter', 'Add filter')}
</Button>
)}
<Button className="w-full" onClick={onStart} disabled={starting}>
{starting ? (
<>
<span className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t('tracing.connecting', 'Connecting...')}
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
{t('tracing.startTracing', 'Start tracing')}
</>
)}
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -1,76 +1,61 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<TraceEvent[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState<Loaded>({ 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 (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
@@ -78,47 +63,21 @@ export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
);
}
if (error) {
if (loaded.kind === 'failed') {
return (
<div className="space-y-4">
<Button variant="outline" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('common.back', 'Back')}
</Button>
<div className="text-destructive p-4">{error}</div>
<div className="text-destructive p-4">{loaded.error}</div>
</div>
);
}
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 (
<div className="space-y-6">
@@ -128,52 +87,29 @@ export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
</Button>
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(180px,1fr))]">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-4 w-4" />
{t('tracing.spanType', 'Span Type')}
</div>
<p className="mt-1 text-lg font-semibold truncate">{spanLabel}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
{t('tracing.date', 'Date')}
</div>
<p className="mt-1 text-lg font-semibold">{dateStr}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Timer className="h-4 w-4" />
{t('tracing.duration', 'Duration')}
</div>
<p className="mt-1 text-lg font-semibold">{durationMs !== null ? formatDuration(durationMs) : '-'}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Activity className="h-4 w-4" />
{t('tracing.events', 'Events')}
</div>
<p className="mt-1 text-lg font-semibold">{events.length}</p>
</CardContent>
</Card>
<Fact icon={<FileText className="h-4 w-4" />} name={t('tracing.spanType', 'Span Type')} truncate>
{summary.span ?? '-'}
</Fact>
<Fact icon={<Clock className="h-4 w-4" />} name={t('tracing.date', 'Date')}>
{summary.startedAt
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(
new Date(summary.startedAt),
)
: '-'}
</Fact>
<Fact icon={<Timer className="h-4 w-4" />} name={t('tracing.duration', 'Duration')}>
{summary.durationMs !== null ? longDuration(summary.durationMs) : '-'}
</Fact>
<Fact icon={<Activity className="h-4 w-4" />} name={t('tracing.events', 'Events')}>
{events.length}
</Fact>
</div>
<Card>
<CardContent className="p-4">
<TraceTimeline
events={events}
anchorTimestamp={firstEvent?.timestamp}
anchorTimestamp={summary.startedAt ?? undefined}
explainSubject={(_, index) => ({ '@type': 'TraceEvent', traceId: objectId, index })}
/>
</CardContent>
@@ -181,3 +117,27 @@ export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
</div>
);
}
function Fact({
icon,
name,
truncate,
children,
}: {
icon: ReactNode;
name: string;
truncate?: boolean;
children: ReactNode;
}) {
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{icon}
{name}
</div>
<p className={`mt-1 text-lg font-semibold${truncate ? ' truncate' : ''}`}>{children}</p>
</CardContent>
</Card>
);
}
+136 -247
View File
@@ -1,69 +1,91 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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: <CircleX className="h-4 w-4 text-red-500" />,
span: <Clock className="h-4 w-4 text-muted-foreground" />,
plain: <CircleDot className="h-4 w-4 text-muted-foreground" />,
};
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<HTMLDivElement>(null);
const wasAtBottomRef = useRef(true);
const names = useNames();
const scroller = useRef<HTMLDivElement>(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 (
<div className="flex items-center justify-center p-8 text-muted-foreground">
@@ -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 <CircleX className="h-4 w-4 text-red-500" />;
}
if (eventType.endsWith('-start') || eventType.endsWith('-end')) {
return <Clock className="h-4 w-4 text-muted-foreground" />;
}
return <CircleDot className="h-4 w-4 text-muted-foreground" />;
}
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 (
<div className="relative">
<div ref={containerRef} onScroll={handleScroll} className="max-h-[70vh] overflow-y-auto scroll-smooth">
<div
ref={scroller}
onScroll={(e) => {
const el = e.currentTarget;
atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_PX;
}}
className="max-h-[70vh] overflow-y-auto scroll-smooth"
>
<div className="space-y-4 p-2">
{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 (
<div key={idx} className="flex gap-3">
<div className="mt-1 shrink-0">{eventIcon(event.event)}</div>
<div key={index} className="flex gap-3">
<div className="mt-1 shrink-0">{TONE_ICON[eventTone(event.event)]}</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
{isFallback ? (
<span className="font-mono text-sm bg-muted px-1.5 py-0.5 rounded">{label}</span>
{name.known ? (
<span className="text-sm font-semibold">{name.label}</span>
) : (
<span className="text-sm font-semibold">{label}</span>
<span className="font-mono text-sm bg-muted px-1.5 py-0.5 rounded">{name.label}</span>
)}
{explanation && <p className="text-xs text-muted-foreground mt-0.5">{explanation}</p>}
{name.explanation && <p className="text-xs text-muted-foreground mt-0.5">{name.explanation}</p>}
</div>
<div className="flex shrink-0 items-start gap-2">
{/* 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) && (
<ExplainButton
icon
className="mt-0.5"
title={label}
subject={() => explainSubject(event, idx)}
title={name.label}
subject={() => explainSubject(event, index)}
/>
)}
<div className="text-right">
<div className="text-xs text-muted-foreground">{formatAbsoluteTime(event.timestamp)}</div>
<div className="text-xs text-muted-foreground/60">{formatRelativeTime(event.timestamp)}</div>
<div className="text-xs text-muted-foreground">{clockTime(event.timestamp)}</div>
<div className="text-xs text-muted-foreground/60">{offsetFrom(anchorMs, event.timestamp)}</div>
</div>
</div>
</div>
{event.keyValues.length > 0 && (
<div className="mt-2 space-y-1">
{event.keyValues.map((kv, ki) => (
<TraceKeyValueRow
key={ki}
kv={kv}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
depth={0}
/>
{event.keyValues.map((kv, i) => (
<ValueRow key={i} kv={kv} names={names} depth={0} />
))}
</div>
)}
@@ -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 (
<div className="flex gap-2 text-sm" style={{ paddingLeft: depth * 16 }}>
<span className="shrink-0 text-muted-foreground text-xs min-w-24 text-right pt-0.5">{label}</span>
<span className="shrink-0 text-muted-foreground text-xs min-w-24 text-right pt-0.5">{names.key(kv.key)}</span>
<span className="text-xs pt-0.5">
<TraceValueDisplay
keyName={kv.key}
value={kv.value}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
viewToSection={viewToSection}
depth={depth}
/>
<Value keyName={kv.key} value={kv.value} names={names} depth={depth} />
</span>
</div>
);
}
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<string, string>;
depth: number;
}) {
/** Very large integers can arrive wrapped as `{ source: "<digits>" }`. */
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<string, unknown>)
? ((raw as Record<string, unknown>).source as string)
: raw;
if (isIdKey(keyName)) {
const encoded = intToBase32(numericInput as number | string);
if (keyName === 'queueId') {
const section = viewToSection['x:QueuedMessage'] ?? 'Management';
return (
<Link to={`/${section}/x:QueuedMessage/${encoded}`} className="text-primary underline hover:no-underline">
{encoded}
</Link>
);
}
return <span className="font-mono">{encoded}</span>;
}
const displayNum = typeof numericInput === 'number' ? numericInput : Number(numericInput);
return <>{displayNum.toLocaleString()}</>;
}
case 'Boolean': {
if (keyName === 'tls') {
return <TlsBoolean on={value.value} />;
}
return <>{String(value.value)}</>;
const n = integerOf(value.value);
if (!isIdKey(keyName)) return Number(n).toLocaleString();
const id = idToText(n);
if (keyName !== 'queueId') return <span className="font-mono">{id}</span>;
return (
<Link to={`/${names.queueSection}/x:QueuedMessage/${id}`} className="text-primary underline hover:no-underline">
{id}
</Link>
);
}
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) => (
<span key={i}>
{i > 0 && ', '}
<TraceValueDisplay
keyName={keyName}
value={v}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
viewToSection={viewToSection}
depth={depth}
/>
</span>
))}
</>
);
}
const items = value.value.map((item, i) => (
<Value key={i} keyName={keyName} value={item} names={names} depth={depth} />
));
// Up to three on one line, more one per line.
if (items.length <= 3)
return items.map((item, i) => (
<span key={i}>
{i > 0 && ', '}
{item}
</span>
));
return (
<div className="space-y-0.5">
{value.value.map((v, i) => (
<div key={i}>
<TraceValueDisplay
keyName={keyName}
value={v}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
viewToSection={viewToSection}
depth={depth}
/>
</div>
{items.map((item, i) => (
<div key={i}>{item}</div>
))}
</div>
);
@@ -340,19 +235,13 @@ function TraceValueDisplay({
</pre>
);
}
const { label, explanation } = resolveEventLabel(value.event);
const name = names.event(value.event);
return (
<div className="mt-1 border-l-2 border-muted pl-3 space-y-1">
<div className="text-xs font-semibold">{label}</div>
{explanation && <div className="text-xs text-muted-foreground">{explanation}</div>}
<div className="text-xs font-semibold">{name.label}</div>
{name.explanation && <div className="text-xs text-muted-foreground">{name.explanation}</div>}
{value.value.map((kv, i) => (
<TraceKeyValueRow
key={i}
kv={kv}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
depth={depth + 1}
/>
<ValueRow key={i} kv={kv} names={names} depth={depth + 1} />
))}
</div>
);
@@ -360,6 +249,6 @@ function TraceValueDisplay({
case 'Null':
return <span className="text-muted-foreground">&mdash;</span>;
default:
return <>{JSON.stringify(value)}</>;
return JSON.stringify(value);
}
}
+96
View File
@@ -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 });
});
});
+66
View File
@@ -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,
};
}
+166
View File
@@ -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();
});
});
+120
View File
@@ -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<typeof setTimeout> | 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<string>).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;
}
+79
View File
@@ -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([]);
});
});
+22 -28
View File
@@ -1,45 +1,39 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<string, unknown>;
/**
* 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<Record<string, unknown>>(raw);
return events.map((evt) => ({
event: String(evt.event ?? ''),
timestamp: String(evt.timestamp ?? ''),
keyValues: normalizeKeyValues(evt.keyValues),
return jmapMapToArray<Raw>(raw).map((event) => ({
event: String(event.event ?? ''),
timestamp: String(event.timestamp ?? ''),
keyValues: keyValues(event.keyValues),
}));
}
function normalizeKeyValues(raw: unknown): TraceKeyValue[] {
const kvs = jmapMapToArray<Record<string, unknown>>(raw);
return kvs.map((kv) => ({
key: String(kv.key ?? ''),
value: normalizeTraceValue(kv.value),
}));
function keyValues(raw: unknown): TraceKeyValue[] {
return jmapMapToArray<Raw>(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<string, unknown>;
const type = obj['@type'] as string;
if (type === 'List') {
return { '@type': 'List', value: jmapMapToArray<unknown>(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<unknown>(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;
}
+7 -2
View File
@@ -1,24 +1,28 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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' };