diff --git a/eslint.config.mjs b/eslint.config.mjs index 0b58b0e..0e8bc61 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -76,5 +76,11 @@ export default [ globals: { ...globals.node } } }, + { + files: ["scripts/benchEventList.mjs"], + languageOptions: { + globals: { ...globals.node } + } + }, { ignores: ["dist/", "coverage/", "node_modules/", "*.zip"] }, ]; diff --git a/package.json b/package.json index dfe2704..3752ba4 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "@xyflow/react": "^12.9.2", "dagre": "^0.8.5", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-window": "^2.2.3" }, "devDependencies": { "@eslint/js": "^9.39.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 263e9a4..bb4bd72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-window: + specifier: ^2.2.3 + version: 2.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@eslint/js': specifier: ^9.39.0 @@ -2059,6 +2062,12 @@ packages: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} + react-window@2.2.3: + resolution: {integrity: sha512-gTRqQYC8ojbiXyd9duYFiSn2TJw0ROXCgYjenOvNKITWzK0m0eCvkUsEUM08xvydkMh7ncp+LE0uS3DeNGZxnQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -4685,6 +4694,11 @@ snapshots: react-refresh@0.18.0: {} + react-window@2.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 diff --git a/scripts/benchEventList.mjs b/scripts/benchEventList.mjs new file mode 100644 index 0000000..f8fbe5b --- /dev/null +++ b/scripts/benchEventList.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/* eslint-env node */ +import { performance } from "node:perf_hooks"; + +// Quick micro-benchmark for EventList's heavy CPU work (flattening rows + positions) +// This script generates synthetic traces with nested spans and other events, +// then measures the time to flatten them and compute row positions similarly +// to what EventList does. Run with: node viewer/scripts/benchEventList.mjs + +function randInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +let idCounter = 1; +function nextId(prefix = 'id') { + return `${prefix}-${idCounter++}`; +} + +function makeSpan(id, parentId, tsMs) { + return { + type: 'span', + ts: new Date(tsMs).toISOString(), + sessionId: 'demo', + level: 'info', + ctx: { traceId: 't1', spanId: id, ...(parentId ? { parentSpanId: parentId } : {}) }, + provider: 'openai', + model: 'gpt-test', + operation: 'op:fake', + durationMs: randInt(1, 2000), + status: 'ok', + }; +} + +function makeMessage(id, parentId, tsMs) { + return { + type: 'message', + ts: new Date(tsMs).toISOString(), + sessionId: 'demo', + level: 'info', + ctx: { traceId: 't1', spanId: id, ...(parentId ? { parentSpanId: parentId } : {}) }, + provider: 'openai', + model: 'gpt-test', + role: 'user', + content: 'hello world', + format: 'text', + }; +} + +// generate a tree-like trace. returns an array of events (spans and child events) +function generateTrace(targetCount) { + const events = []; + let ts = Date.now() - 10000; + + function addSpanTree(parentId, depth) { + if (events.length >= targetCount) return; + const spanId = nextId('s'); + events.push(makeSpan(spanId, parentId, ts)); + ts += randInt(1, 20); + + // add some inner events + const inner = randInt(0, depth > 4 ? 2 : 6); + for (let i = 0; i < inner && events.length < targetCount; i++) { + if (Math.random() < 0.5) { + events.push(makeMessage(nextId('m'), spanId, ts)); + } else { + events.push(makeMessage(nextId('m'), spanId, ts)); + } + ts += randInt(1, 10); + } + + const children = Math.random() < 0.7 && depth < 6 ? randInt(0, 3) : 0; + for (let c = 0; c < children && events.length < targetCount; c++) { + addSpanTree(spanId, depth + 1); + } + } + + const roots = Math.max(1, Math.min(20, Math.floor(targetCount / 5))); + for (let r = 0; r < roots && events.length < targetCount; r++) { + addSpanTree(undefined, 0); + } + + // some orphans + const orphans = Math.min(10, Math.floor(targetCount / 50)); + for (let o = 0; o < orphans && events.length < targetCount; o++) { + events.unshift(makeMessage(nextId('o'), undefined, ts - randInt(50, 300))); + } + + return events.slice(0, targetCount); +} + +// flattenEventRows implementation copied from EventList (simplified) +function flattenEventRows({ roots, orphans }) { + const rows = []; + orphans.forEach((event, index) => { + rows.push({ key: `orphan-${event.ts ?? 'ts'}-${index}`, depth: 0, event }); + }); + + function visit(node, depth) { + rows.push({ key: `span-${node.id}`, depth, event: node.event }); + node.events.forEach((event, index) => { + rows.push({ key: `evt-${node.id}-${event.ts ?? 'ts'}-${index}`, depth: depth + 1, event }); + }); + node.children.forEach((child) => visit(child, depth + 1)); + } + + roots.forEach((node) => visit(node, 0)); + return rows; +} + +// buildSpanForest simplified: for our synthetic input we'll group spans by ctx.spanId and parentSpanId +function buildSpanForest(events) { + const spanMap = new Map(); + const orphans = []; + + // collect spans separately and other events + for (const e of events) { + if (e.type === 'span') { + const id = e.ctx?.spanId ?? nextId('s'); + spanMap.set(id, { id, event: e, events: [], children: [], parent: e.ctx?.parentSpanId }); + } + } + + // attach non-span events to their span if possible + for (const e of events) { + if (e.type === 'span') continue; + const parent = e.ctx?.parentSpanId; + if (parent && spanMap.has(parent)) { + spanMap.get(parent).events.push(e); + } else { + orphans.push(e); + } + } + + // build tree by parent pointers + for (const node of spanMap.values()) { + if (node.parent && spanMap.has(node.parent)) { + spanMap.get(node.parent).children.push(node); + } + } + + // roots are those without a parent or whose parent wasn't found + const roots = Array.from(spanMap.values()).filter((n) => !n.parent || !spanMap.has(n.parent)); + return { roots, orphans }; +} + +function computePositions(flattenedRows, rowHeights) { + const ROW_GAP_PX = 12; + const ESTIMATED_ROW_HEIGHT = 220; + const ESTIMATED_ROW_SIZE = ESTIMATED_ROW_HEIGHT + ROW_GAP_PX; + const positions = []; + let cursor = 0; + flattenedRows.forEach((row, index) => { + const isLast = index === flattenedRows.length - 1; + const size = rowHeights[row.key] ?? (isLast ? ESTIMATED_ROW_HEIGHT : ESTIMATED_ROW_SIZE); + positions.push({ start: cursor, size }); + cursor += size; + }); + return { positions, totalSize: cursor }; +} + +function benchOnce(eventCount) { + const events = generateTrace(eventCount); + const t0 = performance.now(); + const forest = buildSpanForest(events); + const t1 = performance.now(); + const flattened = flattenEventRows(forest); + const t2 = performance.now(); + const pos = computePositions(flattened, {}); + const t3 = performance.now(); + return { + eventCount, + buildForestMs: t1 - t0, + flattenMs: t2 - t1, + positionsMs: t3 - t2, + rows: flattened.length, + totalSize: pos.totalSize, + }; +} + +function benchSizes(sizes, runs = 3) { + for (const size of sizes) { + const results = []; + for (let i = 0; i < runs; i++) { + // reset id counter to keep ids small and deterministic-ish across runs + idCounter = 1; + results.push(benchOnce(size)); + } + + const avg = (k) => (results.reduce((s, r) => s + r[k], 0) / results.length).toFixed(2); + console.log(`\n== size: ${size} events (${runs} runs) ==`); + console.log(`avg buildForest: ${avg('buildForestMs')} ms`); + console.log(`avg flatten: ${avg('flattenMs')} ms`); + console.log(`avg positions: ${avg('positionsMs')} ms`); + console.log(`rows produced: ${results[0].rows} (approx)`); + } +} + +// Run benchmarks: small, medium, large +benchSizes([100, 1000, 5000], 4); diff --git a/src/App.tsx b/src/App.tsx index c70ebdb..5ae54c5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,7 @@ export default function App() { followLatest, bottomRef, pendingCount, + setListApi, } = useLiveStreaming({ appendEvents }); const [view, setView] = useState("list"); @@ -87,7 +88,11 @@ export default function App() { {view === "list" ? ( - + ) : ( )} diff --git a/src/__tests__/App.spec.tsx b/src/__tests__/App.spec.tsx index aeff445..58267a9 100644 --- a/src/__tests__/App.spec.tsx +++ b/src/__tests__/App.spec.tsx @@ -1,8 +1,15 @@ +import "@testing-library/jest-dom"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + import "@testing-library/jest-dom"; +vi.mock("../data/sampleTrace", async () => { + const mod = await import("./fixtures/deterministicTrace"); + return { SAMPLE_TRACE: mod.DETERMINISTIC_TRACE }; +}); + import App from "../App"; describe("App", () => { @@ -32,7 +39,7 @@ describe("App", () => { ).toBeInTheDocument(); // The list view's detailed text is no longer visible // expect(screen.queryByText(/Summarize this./i)).not.toBeInTheDocument(); - expect(screen.queryByTestId("event-list")).toBeNull() + expect(screen.queryByTestId("event-list")).toBeNull(); // Act: Click back to "List" await user.click(screen.getByRole("button", { name: "List" })); diff --git a/src/__tests__/EventList.spec.tsx b/src/__tests__/EventList.spec.tsx new file mode 100644 index 0000000..baad790 --- /dev/null +++ b/src/__tests__/EventList.spec.tsx @@ -0,0 +1,99 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { EventList } from "../components/EventList"; + +import type { AppTracerEvent } from "../types/events"; +import type { MutableRefObject } from "react"; + +describe("EventList virtualization", () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it("renders only the initial window for large datasets", () => { + const events = buildMessageEvents(150); + render( + {}} + /> + ); + + expect(screen.getByText("Event #0")).toBeInTheDocument(); + expect(screen.queryByText("Event #149")).toBeNull(); + }); + + it("reveals deeper rows after scrolling", async () => { + const events = buildMessageEvents(220); + render( + {}} + /> + ); + + const list = screen.getByTestId("event-list") as HTMLElement; + mockScrollableList(list); + + list.scrollTop = 50000; + fireEvent.scroll(list); + + await waitFor(() => { + expect(screen.getByText("Event #219")).toBeInTheDocument(); + }); + }); +}); + +function buildMessageEvents(count: number): AppTracerEvent[] { + return Array.from({ length: count }, (_, index) => ({ + type: "message", + ts: new Date(2024, 0, 1, 0, 0, index).toISOString(), + sessionId: "session", + level: "info", + ctx: { traceId: "trace", spanId: `msg-${index}` }, + role: "user", + provider: "openai", + model: "gpt-4o-mini", + content: `Event #${index}`, + format: "text", + })); +} + +function mockScrollableList(element: HTMLElement) { + Object.defineProperty(element, "clientHeight", { + configurable: true, + value: 600, + }); + Object.defineProperty(element, "scrollHeight", { + configurable: true, + value: 60000, + }); + Object.defineProperty(element, "scrollTop", { + configurable: true, + writable: true, + value: 0, + }); + vi.spyOn(element, "getBoundingClientRect").mockImplementation(() => { + return { + x: 0, + y: 0, + width: 800, + height: 600, + top: 0, + left: 0, + right: 800, + bottom: 600, + toJSON() { + return {}; + }, + } as DOMRect; + }); +} + +function createBottomRef(): MutableRefObject { + return { current: null }; +} diff --git a/src/__tests__/fixtures/deterministicTrace.ts b/src/__tests__/fixtures/deterministicTrace.ts new file mode 100644 index 0000000..57c7195 --- /dev/null +++ b/src/__tests__/fixtures/deterministicTrace.ts @@ -0,0 +1,87 @@ +import type { AppTracerEvent } from "../../types/events"; + +export const DETERMINISTIC_TRACE: AppTracerEvent[] = [ + { + type: "span", + ts: "2024-01-01T10:00:00.000Z", + sessionId: "demo", + level: "info", + ctx: { traceId: "t1", spanId: "root" }, + provider: "openai", + model: "gpt-4o-mini", + operation: "app:request", + durationMs: 1200, + status: "ok", + }, + { + type: "span", + ts: "2024-01-01T10:00:00.100Z", + sessionId: "demo", + level: "info", + ctx: { traceId: "t1", spanId: "child-a", parentSpanId: "root" }, + provider: "openai", + model: "gpt-4o-mini", + operation: "llm:completion", + durationMs: 800, + status: "ok", + }, + { + type: "message", + ts: "2024-01-01T10:00:00.150Z", + sessionId: "demo", + level: "info", + ctx: { traceId: "t1", spanId: "m1", parentSpanId: "child-a" }, + provider: "openai", + model: "gpt-4o-mini", + role: "user", + content: "Summarize this.", + format: "text", + }, + { + type: "tool_call", + ts: "2024-01-01T10:00:00.300Z", + sessionId: "demo", + level: "info", + ctx: { traceId: "t1", spanId: "tc1", parentSpanId: "child-a" }, + provider: "openai", + model: "gpt-4o-mini", + tool: "searchDocs", + input: { q: "vector db" }, + }, + { + type: "tool_result", + ts: "2024-01-01T10:00:00.500Z", + sessionId: "demo", + level: "info", + ctx: { traceId: "t1", spanId: "tr1", parentSpanId: "child-a" }, + provider: "openai", + model: "gpt-4o-mini", + tool: "searchDocs", + output: { hits: 3 }, + ok: true, + latencyMs: 100, + }, + { + type: "span", + ts: "2024-01-01T10:00:00.950Z", + sessionId: "demo", + level: "info", + ctx: { traceId: "t1", spanId: "child-b", parentSpanId: "root" }, + provider: "openai", + model: "gpt-4o-mini", + operation: "db:query", + durationMs: 300, + status: "ok", + attrs: { table: "docs", where: "topic='vector'" }, + }, + { + type: "message", + ts: "2024-01-01T09:59:59.900Z", + sessionId: "demo", + level: "debug", + ctx: { traceId: "t1", spanId: "prelude" }, + role: "system", + content: "Trabzonspor!", + format: "text", + }, +]; diff --git a/src/__tests__/graphLayout.spec.ts b/src/__tests__/graphLayout.spec.ts index b7d3e34..ad46b9a 100644 --- a/src/__tests__/graphLayout.spec.ts +++ b/src/__tests__/graphLayout.spec.ts @@ -1,14 +1,17 @@ import { describe, it, expect } from "vitest"; -import { SAMPLE_TRACE } from "../data/sampleTrace"; import { type AppTracerEvent } from "../types/events"; import { buildSpanForest } from "../utils/buildSpanTree"; import { transformForestToFlow } from "../utils/graphLayout"; import { normalizeEvent } from "../utils/normalizeEvent"; +import { DETERMINISTIC_TRACE } from "./fixtures/deterministicTrace"; + describe("transformForestToFlow", () => { // 1. Process our sample trace exactly as the app does - const normalizedEvents = SAMPLE_TRACE.map(normalizeEvent) as AppTracerEvent[]; + const normalizedEvents = DETERMINISTIC_TRACE.map( + normalizeEvent + ) as AppTracerEvent[]; const forest = buildSpanForest(normalizedEvents); const { nodes, edges } = transformForestToFlow(forest); diff --git a/src/__tests__/plugins.spec.tsx b/src/__tests__/plugins.spec.tsx index f60ab72..822f72e 100644 --- a/src/__tests__/plugins.spec.tsx +++ b/src/__tests__/plugins.spec.tsx @@ -44,7 +44,11 @@ describe("PluginProvider + EventExtrasSlot", () => { render( - + {}} + /> ); diff --git a/src/__tests__/providerBadge.spec.tsx b/src/__tests__/providerBadge.spec.tsx index 732a094..a421e7e 100644 --- a/src/__tests__/providerBadge.spec.tsx +++ b/src/__tests__/providerBadge.spec.tsx @@ -37,6 +37,8 @@ describe("Provider/Model badge", () => { format: "text", }), ]} + bottomRef={{ current: null }} + onListApiChange={() => {}} /> ); diff --git a/src/components/EventList.tsx b/src/components/EventList.tsx index ed3bdd1..2d36e91 100644 --- a/src/components/EventList.tsx +++ b/src/components/EventList.tsx @@ -1,16 +1,28 @@ -import { useMemo, type CSSProperties } from "react"; +import { + useEffect, + useMemo, + useState, + useRef, + memo, + type CSSProperties, + type RefObject, +} from "react"; +import { + List, + useDynamicRowHeight, + type RowComponentProps, +} from "react-window"; import { EventExtrasSlot } from "../plugins"; -import { - buildSpanTree, - buildSpanForest, - type SpanNode, -} from "../utils/buildSpanTree"; +import { buildSpanForest, type SpanNode } from "../utils/buildSpanTree"; +import type { EventListHandler } from "./EventListHandle"; import type { AppTracerEvent } from "../types/events"; interface EventListProps { events: AppTracerEvent[]; + bottomRef: RefObject; + onListApiChange?: (api: EventListHandler | null) => void; } const PROVIDER_COLORS: Record = { @@ -20,8 +32,53 @@ const PROVIDER_COLORS: Record = { azure: "#0078d4", }; -export function EventList({ events }: EventListProps) { +const ROW_GAP_PX = 12; +const ESTIMATED_ROW_HEIGHT = 220; +const ESTIMATED_ROW_SIZE = ESTIMATED_ROW_HEIGHT + ROW_GAP_PX; + +interface FlattenedRow { + key: string; + depth: number; + event: AppTracerEvent; +} + +export function EventList({ + events, + bottomRef, + onListApiChange, +}: EventListProps) { const { roots, orphans } = useMemo(() => buildSpanForest(events), [events]); + const flattenedRows = useMemo( + () => flattenEventRows({ roots, orphans }), + [roots, orphans] + ); + const rowData = useMemo( + () => ({ + rows: flattenedRows, + rowCount: flattenedRows.length + 1, // sentinel row + }), + [flattenedRows] + ); + const [listHeight, setListHeight] = useState(() => + typeof window !== "undefined" ? window.innerHeight : 600 + ); + const dynamicRowHeight = useDynamicRowHeight({ + defaultRowHeight: ESTIMATED_ROW_SIZE, + key: flattenedRows.length, + }); + const listRef = useRef(null); + + useEffect(() => { + onListApiChange?.(listRef.current); + return () => onListApiChange?.(null); + }, [onListApiChange]); + + useEffect(() => { + if (typeof window === "undefined") return; + const onResize = () => setListHeight(window.innerHeight); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); if (events.length === 0) { return ( @@ -37,53 +94,81 @@ export function EventList({ events }: EventListProps) { } return ( -
- {/* Top-level non-span events (orphans) */} - {orphans.map((e, i) => ( - - ))} - {/* Span tree roots */} - {roots.map((node) => ( - - ))} -
+ ); } -/** - * Recursive component to render a span and its children. - * This component itself renders *nothing*, it just maps nodes to EventRows. - */ -function SpanNodeView({ node, depth }: { node: SpanNode; depth: number }) { +type VirtualizedRowProps = { + rows: FlattenedRow[]; + rowCount: number; + sentinelRef: RefObject; +}; + +function VirtualizedRow({ + ariaAttributes, + index, + style, + rows, + rowCount, + sentinelRef, +}: RowComponentProps) { + const isSentinel = index === rowCount - 1; + if (isSentinel) { + return ( +
+ ); + } + const row = rows[index]; + if (!row) { + return
; + } return ( - <> - {/* Render the span row itself at the current depth */} - - - {/* Render attached events, indented one level deeper */} - {node.events.map((e, i) => ( - - ))} - - {/* Render children, indented one level deeper */} - {node.children.map((child) => ( - - ))} - +
+ +
); } /** * Renders a single event row, with indentation. */ -function EventRow({ event, depth }: { event: AppTracerEvent; depth: number }) { - const indentStyle: CSSProperties = { - // Apply the margin to the row itself - marginLeft: depth * 24, // 24px per level - // Border to visualize the hierarchy - borderLeft: depth > 0 ? "2px solid rgba(148, 163, 184, 0.1)" : "none", - paddingLeft: depth > 0 ? "1.1rem" : "1.2rem", - }; +interface EventRowProps { + event: AppTracerEvent; + depth: number; +} + +function EventRowInner({ event, depth }: EventRowProps) { + const indentStyle = useMemo( + () => ({ + // Apply the margin to the row itself + marginLeft: depth * 24, // 24px per level + // Border to visualize the hierarchy + borderLeft: depth > 0 ? "2px solid rgba(148, 163, 184, 0.1)" : "none", + paddingLeft: depth > 0 ? "1.1rem" : "1.2rem", + }), + [depth] + ); return (
@@ -149,7 +234,9 @@ function EventRow({ event, depth }: { event: AppTracerEvent; depth: number }) { ); } -function EventBody({ event }: { event: AppTracerEvent }) { +const EventRow = memo(EventRowInner); + +function EventBodyInner({ event }: { event: AppTracerEvent }) { switch (event.type) { case "message": return ( @@ -231,6 +318,8 @@ function EventBody({ event }: { event: AppTracerEvent }) { } } +const EventBody = memo(EventBodyInner); + function ProviderBadge({ provider, model, @@ -277,18 +366,22 @@ function ProviderBadge({ ); } -function CodeBlock({ +const CodeBlock = memo(function CodeBlock({ value, collapsed, }: { value: unknown; collapsed?: boolean; }) { - if (value == null) return null; - const json = - typeof value === "string" ? value : JSON.stringify(value, null, 2); + // Memoize expensive stringification so repeated renders don't recompute it + const json = useMemo(() => { + if (value == null) return null; + return typeof value === "string" ? value : JSON.stringify(value, null, 2); + }, [value]); + + if (json == null) return null; return
{json}
; -} +}); function formatTimestamp(ts?: string) { if (!ts) return "Unknown time"; @@ -326,3 +419,37 @@ const codeBlockStyle = (collapsed?: boolean): CSSProperties => ({ whiteSpace: "pre-wrap", overflowWrap: "anywhere", }); + +function flattenEventRows({ + roots, + orphans, +}: { + roots: SpanNode[]; + orphans: AppTracerEvent[]; +}): FlattenedRow[] { + const rows: FlattenedRow[] = []; + + orphans.forEach((event, index) => { + rows.push({ + key: `orphan-${event.ts ?? "ts"}-${index}`, + depth: 0, + event, + }); + }); + + const visit = (node: SpanNode, depth: number) => { + rows.push({ key: `span-${node.id}`, depth, event: node.event }); + node.events.forEach((event, index) => { + rows.push({ + key: `evt-${node.id}-${event.ts ?? "ts"}-${index}`, + depth: depth + 1, + event, + }); + }); + node.children.forEach((child) => visit(child, depth + 1)); + }; + + roots.forEach((node) => visit(node, 0)); + + return rows; +} diff --git a/src/components/EventListHandle.ts b/src/components/EventListHandle.ts new file mode 100644 index 0000000..616d9a7 --- /dev/null +++ b/src/components/EventListHandle.ts @@ -0,0 +1,8 @@ +export interface EventListHandler { + readonly element: HTMLDivElement | null; + scrollToRow(config: { + align?: "auto" | "center" | "end" | "smart" | "start"; + behavior?: "auto" | "instant" | "smooth"; + index: number; + }): void; +} diff --git a/src/components/EventsPanel.tsx b/src/components/EventsPanel.tsx index cc0b79d..a724861 100644 --- a/src/components/EventsPanel.tsx +++ b/src/components/EventsPanel.tsx @@ -1,19 +1,28 @@ import { EventList } from "./EventList"; +import type { EventListHandler } from "./EventListHandle"; import type { AppTracerEvent } from "../types/events"; import type { RefObject } from "react"; interface EventsPanelProps { events: AppTracerEvent[]; bottomRef: RefObject; + onListApiChange?: (api: EventListHandler | null) => void; } -export function EventsPanel({ events, bottomRef }: EventsPanelProps) { +export function EventsPanel({ + events, + bottomRef, + onListApiChange, +}: EventsPanelProps) { return (
- -
+
); diff --git a/src/data/sampleTrace.ts b/src/data/sampleTrace.ts index 4911b66..1817ca6 100644 --- a/src/data/sampleTrace.ts +++ b/src/data/sampleTrace.ts @@ -1,94 +1,282 @@ import type { AppTracerEvent } from "../types/events"; -export const SAMPLE_TRACE: AppTracerEvent[] = [ - // root span - { - type: "span", - ts: "2024-01-01T10:00:00.000Z", - sessionId: "demo", - level: "info", - ctx: { traceId: "t1", spanId: "root" }, - provider: "openai", - model: "gpt-4o-mini", - operation: "app:request", - durationMs: 1200, - status: "ok", - }, - // child span under root - { +// Generate a realistic, variable-sized sample trace. The generator chooses +// between a normal-sized trace and a "very big" trace randomly. The output +// contains spans, nested child spans, messages, tool calls and results, +// and some orphan top-level events to mimic telemetry from a large project. + +function randInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function pick(arr: T[]) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +let idCounter = 1; +function nextId(prefix = "id") { + return `${prefix}-${idCounter++}`; +} + +const PROVIDERS = ["openai", "anthropic", "google", "azure"]; +const MODELS: Record = { + openai: ["gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"], + anthropic: ["claude-2", "claude-1.3"], + google: ["gemini-mini", "paLM-2"], + azure: ["azure-gpt"], +}; + +const LEVELS = ["info", "debug", "error", "warn"]; // telemetry severity +const ROLES = ["user", "assistant", "system"]; + +function nowIso(offsetMs: number) { + return new Date(Date.now() + offsetMs).toISOString(); +} + +function makeSpan(opts: { + traceId: string; + spanId: string; + parentSpanId?: string; + provider: string; + model: string; + operation?: string; + durationMs?: number; + status?: string; + tsMs: number; +}): AppTracerEvent { + return { type: "span", - ts: "2024-01-01T10:00:00.100Z", + ts: nowIso(opts.tsMs), sessionId: "demo", - level: "info", - ctx: { traceId: "t1", spanId: "child-a", parentSpanId: "root" }, - provider: "openai", - model: "gpt-4o-mini", - operation: "llm:completion", - durationMs: 800, - status: "ok", - }, - // message inside child span - { + level: pick(LEVELS), + ctx: { + traceId: opts.traceId, + spanId: opts.spanId, + ...(opts.parentSpanId ? { parentSpanId: opts.parentSpanId } : {}), + }, + provider: opts.provider, + model: opts.model, + operation: opts.operation ?? "op:unknown", + durationMs: opts.durationMs ?? randInt(5, 2000), + status: opts.status ?? "ok", + } as AppTracerEvent; +} + +function makeMessage(opts: { + traceId: string; + spanId: string; + parentSpanId?: string; + role?: string; + content?: string; + tsMs: number; +}): AppTracerEvent { + return { type: "message", - ts: "2024-01-01T10:00:00.150Z", + ts: nowIso(opts.tsMs), sessionId: "demo", - level: "info", - ctx: { traceId: "t1", spanId: "m1", parentSpanId: "child-a" }, - provider: "openai", - model: "gpt-4o-mini", - role: "user", - content: "Summarize this.", + level: pick(LEVELS), + ctx: { + traceId: opts.traceId, + spanId: opts.spanId, + ...(opts.parentSpanId ? { parentSpanId: opts.parentSpanId } : {}), + }, + provider: pick(PROVIDERS), + model: "", + role: opts.role ?? pick(ROLES), + content: + opts.content ?? + pick([ + "Hello", + "Ping", + "Summarize this", + "What is the status?", + "Run query", + ]), format: "text", - }, - // tool_call inside child span - { + } as AppTracerEvent; +} + +function makeToolCall(opts: { + traceId: string; + spanId: string; + parentSpanId?: string; + tool?: string; + input?: unknown; + tsMs: number; +}): AppTracerEvent { + return { type: "tool_call", - ts: "2024-01-01T10:00:00.300Z", + ts: nowIso(opts.tsMs), sessionId: "demo", - level: "info", - ctx: { traceId: "t1", spanId: "tc1", parentSpanId: "child-a" }, - provider: "openai", - model: "gpt-4o-mini", - tool: "searchDocs", - input: { q: "vector db" }, - }, - // tool_result inside child span - { + level: pick(LEVELS), + ctx: { + traceId: opts.traceId, + spanId: opts.spanId, + ...(opts.parentSpanId ? { parentSpanId: opts.parentSpanId } : {}), + }, + provider: pick(PROVIDERS), + model: "", + tool: opts.tool ?? pick(["searchDocs", "dbQuery", "httpFetch"]), + input: opts.input ?? { q: "example" }, + } as AppTracerEvent; +} + +function makeToolResult(opts: { + traceId: string; + spanId: string; + parentSpanId?: string; + tool?: string; + output?: unknown; + ok?: boolean; + latencyMs?: number; + tsMs: number; +}): AppTracerEvent { + return { type: "tool_result", - ts: "2024-01-01T10:00:00.500Z", + ts: nowIso(opts.tsMs), sessionId: "demo", - level: "info", - ctx: { traceId: "t1", spanId: "tr1", parentSpanId: "child-a" }, - provider: "openai", - model: "gpt-4o-mini", - tool: "searchDocs", - output: { hits: 3 }, - ok: true, - latencyMs: 100, - }, - // another child span under root - { - type: "span", - ts: "2024-01-01T10:00:00.950Z", + level: pick(LEVELS), + ctx: { + traceId: opts.traceId, + spanId: opts.spanId, + ...(opts.parentSpanId ? { parentSpanId: opts.parentSpanId } : {}), + }, + provider: pick(PROVIDERS), + model: "", + tool: opts.tool ?? pick(["searchDocs", "dbQuery", "httpFetch"]), + output: opts.output ?? { result: randInt(0, 100) }, + ok: opts.ok ?? true, + latencyMs: opts.latencyMs ?? randInt(5, 600), + } as AppTracerEvent; +} + +function makeUsage(opts: { + traceId: string; + spanId: string; + tsMs: number; +}): AppTracerEvent { + return { + type: "usage", + ts: nowIso(opts.tsMs), sessionId: "demo", level: "info", - ctx: { traceId: "t1", spanId: "child-b", parentSpanId: "root" }, - provider: "openai", - model: "gpt-4o-mini", - operation: "db:query", - durationMs: 300, - status: "ok", - attrs: { table: "docs", where: "topic='vector'" }, - }, - // top-level non-span (orphan), will render above root span - { - type: "message", - ts: "2024-01-01T09:59:59.900Z", - sessionId: "demo", - level: "debug", - ctx: { traceId: "t1", spanId: "prelude" }, - role: "system", - content: "Trabzonspor!", - format: "text", - }, -]; + ctx: { traceId: opts.traceId, spanId: opts.spanId }, + inputTokens: randInt(1, 1000), + outputTokens: randInt(0, 1000), + cost: Math.random() * 0.01, + } as AppTracerEvent; +} + +// Build a trace with a target number of events. The generator will create +// multiple root spans and recursively add child spans, messages and tool +// interactions until the target is reached (or a safety limit). +function generateTrace(targetEvents: number): AppTracerEvent[] { + const events: AppTracerEvent[] = []; + const traceId = `trace-${nextId("t")}`; + let tsOffset = -10000; // start a bit in the past + + const maxRoots = Math.max(1, Math.min(50, Math.floor(targetEvents / 5))); + const rootCount = randInt(1, Math.min(maxRoots, 12)); + + function ensureModelFor(provider: string) { + const arr = MODELS[provider] ?? ["generic-model"]; + return pick(arr); + } + + function addSpanTree(parentSpanId: string | undefined, depth: number) { + if (events.length >= targetEvents) return; + const spanId = nextId(parentSpanId ? "s" : "root"); + const provider = pick(PROVIDERS); + const span = makeSpan({ + traceId, + spanId, + parentSpanId: parentSpanId ?? undefined, + provider, + model: ensureModelFor(provider), + operation: pick([ + "http:request", + "llm:completion", + "db:query", + "cache:get", + ]), + durationMs: randInt(1, 2000), + status: pick(["ok", "error"]), + tsMs: tsOffset, + }); + events.push(span); + tsOffset += randInt(1, 50); + + // Add a few messages and tool calls inside this span + const innerCount = randInt(0, depth > 3 ? 2 : 6); + for (let i = 0; i < innerCount && events.length < targetEvents; i++) { + const chooseType = Math.random(); + if (chooseType < 0.4) { + events.push( + makeMessage({ + traceId, + spanId: nextId("m"), + parentSpanId: spanId, + tsMs: tsOffset, + }) + ); + } else if (chooseType < 0.75) { + const tcId = nextId("tc"); + events.push( + makeToolCall({ + traceId, + spanId: tcId, + parentSpanId: spanId, + tsMs: tsOffset, + }) + ); + tsOffset += randInt(1, 20); + events.push( + makeToolResult({ + traceId, + spanId: nextId("tr"), + parentSpanId: spanId, + tsMs: tsOffset, + }) + ); + } else { + events.push( + makeUsage({ traceId, spanId: nextId("u"), tsMs: tsOffset }) + ); + } + tsOffset += randInt(1, 40); + } + + // Recursively add child spans with decreasing probability + const childCount = Math.random() < 0.6 && depth < 5 ? randInt(0, 3) : 0; + for (let c = 0; c < childCount && events.length < targetEvents; c++) { + addSpanTree(spanId, depth + 1); + } + } + + // Create root spans + for (let r = 0; r < rootCount && events.length < targetEvents; r++) { + addSpanTree(undefined, 0); + } + + // Add some orphan top-level events to mimic other telemetry + const orphanCount = randInt(1, Math.min(20, Math.floor(targetEvents / 20))); + for (let o = 0; o < orphanCount && events.length < targetEvents; o++) { + events.unshift( + makeMessage({ + traceId, + spanId: nextId("orphan"), + role: pick(ROLES), + tsMs: tsOffset - randInt(100, 500), + }) + ); + } + + // Trim in case we overshot + return events.slice(0, targetEvents); +} + +// Decide size: 50% chance of a very large trace, otherwise normal. +const isBig = Math.random() < 0.5; +const targetEvents = isBig ? randInt(2000, 8000) : randInt(20, 300); + +export const SAMPLE_TRACE: AppTracerEvent[] = generateTrace(targetEvents); diff --git a/src/hooks/useLiveStreaming.ts b/src/hooks/useLiveStreaming.ts index 35c448a..6540a49 100644 --- a/src/hooks/useLiveStreaming.ts +++ b/src/hooks/useLiveStreaming.ts @@ -6,10 +6,12 @@ import { type RefObject, } from "react"; -import { type AppTracerEvent } from "../types/events"; import { LiveClient } from "../utils/liveClient"; import { normalizeEvent } from "../utils/normalizeEvent"; +import type { EventListHandler } from "../components/EventListHandle"; +import type { AppTracerEvent } from "../types/events"; + interface UseLiveStreamingParams { appendEvents: (events: AppTracerEvent[]) => void; } @@ -24,6 +26,7 @@ export interface LiveStreamingState { followLatest: () => void; bottomRef: RefObject; pendingCount: number; + setListApi: (api: EventListHandler | null) => void; } export function useLiveStreaming({ @@ -43,6 +46,8 @@ export function useLiveStreaming({ const pendingRef = useRef([]); const clientRef = useRef(undefined); + const [listApi, setListApi] = useState(null); + const scrollContainer = listApi?.element ?? null; const clearAutoScrollTimeout = useCallback(() => { if (typeof window === "undefined") return; @@ -57,8 +62,23 @@ export function useLiveStreaming({ if (typeof window === "undefined") return; clearAutoScrollTimeout(); autoScrollingRef.current = true; + window.requestAnimationFrame(() => { - bottomRef.current?.scrollIntoView({ behavior, block: "end" }); + if (listApi?.scrollToRow) { + listApi.scrollToRow({ + index: Number.MAX_SAFE_INTEGER, + align: "end", + behavior, + }); + } else if (scrollContainer) { + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior, + }); + } else { + bottomRef.current?.scrollIntoView({ behavior, block: "end" }); + } + const delay = behavior === "smooth" ? 400 : 50; autoScrollTimeoutRef.current = window.setTimeout(() => { autoScrollingRef.current = false; @@ -66,7 +86,7 @@ export function useLiveStreaming({ }, delay); }); }, - [clearAutoScrollTimeout] + [clearAutoScrollTimeout, listApi, scrollContainer] ); const followLatest = useCallback(() => { @@ -151,29 +171,37 @@ export function useLiveStreaming({ followTailRef.current = atBottom; setFollowTail(atBottom); }, - { root: null, rootMargin: "0px 0px -64px 0px", threshold: 0 } + { + root: scrollContainer ?? null, + rootMargin: "0px 0px -64px 0px", + threshold: 0, + } ); observer.observe(sentinel); return () => observer.disconnect(); - }, []); + }, [scrollContainer]); - useEffect(() => { - if (typeof window === "undefined") return undefined; + const handleScrollEvent = useCallback(() => { + if (!live) return; + if (autoScrollingRef.current) return; + if (!followTailRef.current) return; - const handleScroll = () => { - if (!live) return; - if (autoScrollingRef.current) return; - if (!followTailRef.current) return; + manuallyUnfollowedRef.current = true; + followTailRef.current = false; + setFollowTail(false); + }, [live]); - manuallyUnfollowedRef.current = true; - followTailRef.current = false; - setFollowTail(false); - }; + useEffect(() => { + const target: EventTarget | null = + scrollContainer ?? + (typeof window === "undefined" ? null : window); + if (!target || typeof target.addEventListener !== "function") return; - window.addEventListener("scroll", handleScroll, { passive: true }); - return () => window.removeEventListener("scroll", handleScroll); - }, [live]); + target.addEventListener("scroll", handleScrollEvent, { passive: true }); + return () => + target.removeEventListener?.("scroll", handleScrollEvent as EventListener); + }, [handleScrollEvent, scrollContainer]); useEffect(() => { return () => { @@ -199,5 +227,6 @@ export function useLiveStreaming({ followLatest, bottomRef, pendingCount, + setListApi, }; } diff --git a/src/setupTests.ts b/src/setupTests.ts index 214114e..75bfe64 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -51,9 +51,22 @@ class MockResizeObserver implements ResizeObserver { } observe(target: Element) { + const rect = target.getBoundingClientRect(); + const boxSize = [ + { + blockSize: rect.height, + inlineSize: rect.width, + }, + ]; this.callback( [ - { target, contentRect: target.getBoundingClientRect() }, + { + target, + contentRect: rect, + borderBoxSize: boxSize, + contentBoxSize: boxSize, + devicePixelContentBoxSize: boxSize, + }, ] as ResizeObserverEntry[], this ); diff --git a/src/styles.css b/src/styles.css index d0f296c..f01e615 100644 --- a/src/styles.css +++ b/src/styles.css @@ -78,9 +78,8 @@ a { } .event-list { - display: flex; - flex-direction: column; - gap: 0.75rem; + position: relative; + display: block; } .event-row {