Skip to content

Commit 01dd4c3

Browse files
committed
feat(viewer): implement live tail streaming for events
This commit introduces the "Live Tail" feature to the AccordKit viewer, enabling real-time observation of tracer events as they are generated.
1 parent 7a5f244 commit 01dd4c3

16 files changed

Lines changed: 1111 additions & 195 deletions

eslint.config.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,11 @@ export default [
7070
languageOptions: { globals: { ...globals.browser, ...globals.es2021 } },
7171
rules: { "@typescript-eslint/no-explicit-any": "off" },
7272
},
73+
{
74+
files: ["server/server.js"],
75+
languageOptions: {
76+
globals: { ...globals.node }
77+
}
78+
},
7379
{ ignores: ["dist/", "coverage/", "node_modules/", "*.zip"] },
7480
];

package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
"type": "module",
2626
"scripts": {
2727
"dev": "vite",
28+
"server": "node server/server.js",
29+
"dev:all": "run-p dev server",
2830
"build": "tsc -p tsconfig.node.json && vite build",
2931
"clean": "rimraf dist",
3032
"preview": "vite preview",
@@ -41,23 +43,26 @@
4143
"react-dom": "^18.3.1"
4244
},
4345
"devDependencies": {
44-
"@testing-library/react": "^16.3.0",
4546
"@testing-library/dom": "^10.4.1",
4647
"@testing-library/jest-dom": "^6.9.1",
48+
"@testing-library/react": "^16.3.0",
4749
"@testing-library/user-event": "^14.6.1",
48-
"jsdom": "^27.0.1",
4950
"@types/react": "^19.2.2",
5051
"@types/react-dom": "^19.2.2",
5152
"@typescript-eslint/eslint-plugin": "^8.46.0",
5253
"@typescript-eslint/parser": "^8.46.0",
5354
"@vitejs/plugin-react": "^5.0.4",
55+
"@vitest/coverage-v8": "3.2.4",
5456
"@vitest/eslint-plugin": "^1.3.23",
5557
"eslint": "^9.38.0",
5658
"eslint-config-prettier": "^10.1.8",
5759
"eslint-plugin-import": "^2.32.0",
5860
"eslint-plugin-react-hooks": "^5.1.0",
5961
"eslint-plugin-unused-imports": "^4.2.0",
62+
"express": "^5.1.0",
6063
"globals": "^16.4.0",
64+
"jsdom": "^27.0.1",
65+
"npm-run-all": "^4.1.5",
6166
"prettier": "^3.6.2",
6267
"typescript": "^5.5.0",
6368
"vite": "^5.4.8",

server/server.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import express from "express";
2+
3+
const app = express();
4+
const PORT = process.env.PORT || 1967;
5+
6+
// Allow Vite client to connect from localhost:5173
7+
app.use((_req, res, next) => {
8+
res.setHeader("Access-Control-Allow-Origin", "*");
9+
next();
10+
});
11+
12+
// SSE endpoint
13+
app.get("/api/events", (req, res) => {
14+
res.writeHead(200, {
15+
"Content-Type": "text/event-stream",
16+
"Cache-Control": "no-cache",
17+
Connection: "keep-alive",
18+
});
19+
20+
const send = (event) => {
21+
res.write(`data: ${JSON.stringify(event)}\n\n`);
22+
};
23+
24+
let counter = 0;
25+
const interval = setInterval(() => {
26+
counter++;
27+
const evt = {
28+
type: "span",
29+
ts: new Date().toISOString(),
30+
sessionId: "live-demo",
31+
provider: "demo",
32+
level: "info",
33+
ctx: { traceId: "demo-trace", spanId: `evt-${counter}` },
34+
operation: "demo:heartbeat",
35+
durationMs: Math.floor(Math.random() * 1000),
36+
status: "ok",
37+
attrs: { seq: counter },
38+
};
39+
send(evt);
40+
}, 1000);
41+
42+
req.on("close", () => clearInterval(interval));
43+
});
44+
45+
app.listen(PORT, () =>
46+
console.log(`✅ SSE server running at http://localhost:${PORT}/api/events`)
47+
);

src/App.tsx

Lines changed: 49 additions & 191 deletions
Original file line numberDiff line numberDiff line change
@@ -1,151 +1,37 @@
1-
import { useCallback, useMemo, useState, useDeferredValue } from "react";
2-
31
import { AdvancedFilterBar } from "./components/AdvancedFilterBar";
4-
import { DropZone } from "./components/DropZone";
5-
import { EventList } from "./components/EventList";
2+
import { EventsPanel } from "./components/EventsPanel";
63
import { EventSummary } from "./components/EventSummary";
7-
import { RightPanelSlot, TopBannerSlot } from "./plugins";
8-
import {
9-
DEFAULT_FILTERS,
10-
buildFilterPredicate,
11-
extractFacets,
12-
type FilterState,
13-
} from "./utils/eventFilters";
14-
import { parseJsonLines } from "./utils/parseEvents";
15-
16-
import type { TracerEvent } from "@accordkit/tracer";
17-
18-
const SAMPLE_TRACE = [
19-
// root span
20-
{
21-
type: "span",
22-
ts: "2024-01-01T10:00:00.000Z",
23-
sessionId: "demo",
24-
level: "info",
25-
ctx: { traceId: "t1", spanId: "root" },
26-
provider: "openai",
27-
model: "gpt-4o-mini",
28-
operation: "app:request",
29-
durationMs: 1200,
30-
status: "ok",
31-
},
32-
// child span under root
33-
{
34-
type: "span",
35-
ts: "2024-01-01T10:00:00.100Z",
36-
sessionId: "demo",
37-
level: "info",
38-
ctx: { traceId: "t1", spanId: "child-a", parentSpanId: "root" },
39-
provider: "openai",
40-
model: "gpt-4o-mini",
41-
operation: "llm:completion",
42-
durationMs: 800,
43-
status: "ok",
44-
},
45-
// message inside child span
46-
{
47-
type: "message",
48-
ts: "2024-01-01T10:00:00.150Z",
49-
sessionId: "demo",
50-
level: "info",
51-
ctx: { traceId: "t1", spanId: "m1", parentSpanId: "child-a" },
52-
provider: "openai",
53-
model: "gpt-4o-mini",
54-
role: "user",
55-
content: "Summarize this.",
56-
format: "text",
57-
},
58-
// tool_call inside child span
59-
{
60-
type: "tool_call",
61-
ts: "2024-01-01T10:00:00.300Z",
62-
sessionId: "demo",
63-
level: "info",
64-
ctx: { traceId: "t1", spanId: "tc1", parentSpanId: "child-a" },
65-
provider: "openai",
66-
model: "gpt-4o-mini",
67-
tool: "searchDocs",
68-
input: { q: "vector db" },
69-
},
70-
// tool_result inside child span
71-
{
72-
type: "tool_result",
73-
ts: "2024-01-01T10:00:00.500Z",
74-
sessionId: "demo",
75-
level: "info",
76-
ctx: { traceId: "t1", spanId: "tr1", parentSpanId: "child-a" },
77-
provider: "openai",
78-
model: "gpt-4o-mini",
79-
tool: "searchDocs",
80-
output: { hits: 3 },
81-
ok: true,
82-
latencyMs: 100,
83-
},
84-
// another child span under root
85-
{
86-
type: "span",
87-
ts: "2024-01-01T10:00:00.950Z",
88-
sessionId: "demo",
89-
level: "info",
90-
ctx: { traceId: "t1", spanId: "child-b", parentSpanId: "root" },
91-
provider: "openai",
92-
model: "gpt-4o-mini",
93-
operation: "db:query",
94-
durationMs: 300,
95-
status: "ok",
96-
attrs: { table: "docs", where: "topic='vector'" },
97-
},
98-
// top-level non-span (orphan), will render above root span
99-
{
100-
type: "message",
101-
ts: "2024-01-01T09:59:59.900Z",
102-
sessionId: "demo",
103-
level: "debug",
104-
ctx: { traceId: "t1", spanId: "prelude" },
105-
role: "system",
106-
content: "Trabzonspor!",
107-
format: "text",
108-
},
109-
];
4+
import { FollowEventsPill } from "./components/FollowEventsPill";
5+
import { LiveControls } from "./components/LiveControls";
6+
import { TraceIngestPanel } from "./components/TraceIngestPanel";
7+
import { useLiveStreaming } from "./hooks/useLiveStreaming";
8+
import { useTraceData } from "./hooks/useTraceData";
9+
import { RightPanelSlot } from "./plugins";
11010

11111
export default function App() {
112-
const [events, setEvents] = useState<TracerEvent[]>([]);
113-
const [errors, setErrors] = useState<string[]>([]);
114-
const [filters, setFilters] = useState<FilterState>(DEFAULT_FILTERS);
115-
const [fileName, setFileName] = useState<string | null>(null);
116-
117-
const facets = useMemo(() => extractFacets(events), [events]);
118-
119-
const deferredQuery = useDeferredValue(filters.q);
120-
121-
const filteredEvents = useMemo(() => {
122-
const pred = buildFilterPredicate({ ...filters, q: deferredQuery });
123-
return events.filter(pred);
124-
}, [events, filters, deferredQuery]);
125-
126-
const handleFiles = useCallback(async (files: FileList | File[]) => {
127-
const file = files[0];
128-
if (!file) return;
12+
const {
13+
errors,
14+
facets,
15+
filteredEvents,
16+
filters,
17+
setFilters,
18+
fileName,
19+
handleFiles,
20+
loadSampleTrace,
21+
appendEvents,
22+
} = useTraceData();
12923

130-
const text = await file.text();
131-
const { events: parsedEvents, errors: parseErrors } = parseJsonLines(text);
132-
133-
setEvents(parsedEvents);
134-
setErrors(parseErrors.map((err) => `Line ${err.line}: ${err.message}`));
135-
setFilters(DEFAULT_FILTERS);
136-
setFileName(file.name);
137-
}, []);
138-
139-
const loadSampleTrace = () => {
140-
const serialized = SAMPLE_TRACE.map((event) => JSON.stringify(event)).join(
141-
"\n",
142-
);
143-
const { events: parsed } = parseJsonLines(serialized);
144-
setEvents(parsed);
145-
setErrors([]);
146-
setFilters(DEFAULT_FILTERS);
147-
setFileName("sample-trace.jsonl");
148-
};
24+
const {
25+
live,
26+
toggleLive,
27+
paused,
28+
togglePaused,
29+
received,
30+
followTail,
31+
followLatest,
32+
bottomRef,
33+
pendingCount,
34+
} = useLiveStreaming({ appendEvents });
14935

15036
return (
15137
<div className="app-shell">
@@ -155,54 +41,26 @@ export default function App() {
15541
onChange={setFilters}
15642
facets={facets}
15743
/>
158-
<TopBannerSlot />
159-
<div className="panel" style={{ marginBottom: "1.5rem" }}>
160-
<div className="panel-header">
161-
<h2>Trace Ingest</h2>
162-
<div style={{ display: "flex", gap: "0.75rem" }}>
163-
<button
164-
type="button"
165-
className="filter-button"
166-
onClick={loadSampleTrace}
167-
>
168-
Load sample trace
169-
</button>
170-
{fileName && (
171-
<span
172-
style={{
173-
fontSize: "0.85rem",
174-
color: "rgba(148,163,184,0.85)",
175-
}}
176-
>
177-
Loaded: <strong>{fileName}</strong>
178-
</span>
179-
)}
180-
</div>
181-
</div>
182-
<div className="panel-body">
183-
{errors.length > 0 && (
184-
<div className="error-banner">
185-
<strong>{errors.length} line(s) failed to parse.</strong>
186-
<ul style={{ marginTop: "0.5rem", marginBottom: 0 }}>
187-
{errors.slice(0, 3).map((err, index) => (
188-
<li key={index}>{err}</li>
189-
))}
190-
</ul>
191-
</div>
192-
)}
193-
<DropZone onFiles={handleFiles} />
194-
</div>
195-
</div>
19644

197-
<div className="panel" style={{ marginBottom: "1.5rem" }}>
198-
{/* <div className="panel-header">
199-
<h2>Events</h2>
200-
<FilterBar activeType={filter} onChange={setFilter} />
201-
</div> */}
202-
<div className="panel-body">
203-
<EventList events={filteredEvents} />
204-
</div>
205-
</div>
45+
<LiveControls
46+
live={live}
47+
onToggleLive={toggleLive}
48+
paused={paused}
49+
onTogglePaused={togglePaused}
50+
received={received}
51+
pendingCount={pendingCount}
52+
/>
53+
54+
<TraceIngestPanel
55+
fileName={fileName}
56+
errors={errors}
57+
onLoadSample={loadSampleTrace}
58+
onFiles={handleFiles}
59+
/>
60+
61+
<EventsPanel events={filteredEvents} bottomRef={bottomRef} />
62+
63+
<FollowEventsPill visible={live && !followTail} onFollow={followLatest} />
20664
</main>
20765

20866
<aside>
@@ -217,7 +75,7 @@ export default function App() {
21775
Array.from(new Set(filteredEvents.map((e) => e.type))).map((t) => [
21876
t,
21977
filteredEvents.filter((e) => e.type === t).length,
220-
]),
78+
])
22179
)}
22280
/>
22381
</aside>

0 commit comments

Comments
 (0)