diff --git a/src/dagUtils.ts b/src/dagUtils.ts index a7fd947..d6b580d 100644 --- a/src/dagUtils.ts +++ b/src/dagUtils.ts @@ -539,18 +539,22 @@ export function buildSpanDag(events: LogEvent[]): SpanDag { let node = allSpans.get(spanId); if (!node) { - node = { + const newNode: SpanNode = { span_id: spanId, trace_id: (event.trace_id as string) || '', - parent_span_id: event.parent_span_id as string | undefined, + parent_span_id: (event.parent_span_id as string | null) || null, name: (event.span_name as string) || event.msg.replace(/\.(started|finished)$/, ''), worker_id: event.worker, - bead_id: event.bead, + bead_id: event.bead || null, + start_ts: null, + end_ts: null, + duration_ms: null, status: 'unknown', children: [], attributes: {}, }; - allSpans.set(spanId, node); + allSpans.set(spanId, newNode); + node = newNode; } // Update from .started / .finished events @@ -558,11 +562,11 @@ export function buildSpanDag(events: LogEvent[]): SpanDag { const isFinished = event.msg.endsWith('.finished'); if (isStarted) { - node.start_ts = event.ts; + node.start_ts = event.ts || null; if (event.span_name) node.name = event.span_name as string; } else if (isFinished) { - node.end_ts = event.ts; - node.duration_ms = event.duration_ms; + node.end_ts = event.ts || null; + node.duration_ms = event.duration_ms || null; node.status = event.error ? 'error' : 'ok'; } diff --git a/src/types.ts b/src/types.ts index 966c99d..75d5ab0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1311,8 +1311,8 @@ export interface SpanNode { /** OTLP trace ID */ trace_id: string; - /** Parent span ID (undefined for root spans) */ - parent_span_id?: string; + /** Parent span ID (null for root spans) */ + parent_span_id: string | null; /** Span name (e.g. "bead.lifecycle", "tool.call", "llm.request") */ name: string; @@ -1320,17 +1320,17 @@ export interface SpanNode { /** Worker that emitted this span */ worker_id: string; - /** Associated bead ID (if this is a bead lifecycle span) */ - bead_id?: string; + /** Associated bead ID (null if not associated) */ + bead_id: string | null; - /** Start timestamp (ms from .started event) */ - start_ts?: number; + /** Start timestamp (ms from .started event, null if not available) */ + start_ts: number | null; - /** End timestamp (ms from .finished event) */ - end_ts?: number; + /** End timestamp (ms from .finished event, null if not available) */ + end_ts: number | null; - /** Duration in ms */ - duration_ms?: number; + /** Duration in ms (null if not available) */ + duration_ms: number | null; /** Span completion status */ status: 'ok' | 'error' | 'unknown'; @@ -1359,6 +1359,22 @@ export interface SpanDag { traces: Map; } +/** + * JSON-serializable span DAG response for API endpoints. + * Unlike SpanDag (which uses Maps for internal use), this format + * is designed for HTTP responses and JSON serialization. + */ +export interface SpanDagResponse { + /** Root spans (no parent_span_id or orphaned) */ + roots: SpanNode[]; + + /** Total number of spans in the DAG */ + totalSpans: number; + + /** Trace summary with span counts */ + traces: Array<{ trace_id: string; span_count: number }>; +} + // ============================================ // Git Event Types // ============================================ diff --git a/src/web/server.ts b/src/web/server.ts index d747bf8..ee295f9 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -11,7 +11,7 @@ import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import * as systemdNotify from 'systemd-notify'; import { WebSocketServer, WebSocket } from 'ws'; -import { LogEvent, EventFilter, CrossReferenceEntityType, CrossReferenceRelationship, DagOptions, BeadStatus, SemanticNarrative, NarrativeSegment } from '../types.js'; +import { LogEvent, EventFilter, CrossReferenceEntityType, CrossReferenceRelationship, DagOptions, BeadStatus, SemanticNarrative, NarrativeSegment, SpanDagResponse, SpanNode } from '../types.js'; import { InMemoryEventStore } from '../store.js'; import { refreshDependencyGraph, getDagStats } from '../tui/dagUtils.js'; import { normalizeToLogEvent, EventDeduplicator } from '../normalizer.js'; @@ -1483,6 +1483,87 @@ export function createWebServer(options: WebServerOptions): WebServer { } }); + // GET /api/spans/dag - Get span DAG for visualization + app.get('/api/spans/dag', (req: Request, res: Response) => { + try { + const traceId = req.query.trace_id as string | undefined; + + // Query all events that have span data + const allEvents = store.query(); + const spanEvents = allEvents.filter( + (e) => e.span_id && (e.trace_id || !traceId) + ); + + // Filter by trace_id if specified + const filteredEvents = traceId + ? spanEvents.filter((e) => e.trace_id === traceId) + : spanEvents; + + // Build span nodes and index + const spanMap = new Map(); + const rootSpans: SpanNode[] = []; + const traceCounts = new Map(); + + for (const event of filteredEvents) { + const spanId = event.span_id as string; + const traceId = event.trace_id as string; + + // Count spans per trace + traceCounts.set(traceId, (traceCounts.get(traceId) || 0) + 1); + + // Create span node if not exists + if (!spanMap.has(spanId)) { + const node: SpanNode = { + span_id: spanId, + trace_id: traceId, + parent_span_id: event.parent_span_id as string | null || null, + name: event.span_name as string || event.msg || 'Unknown', + worker_id: event.worker, + bead_id: event.bead || null, + start_ts: event.start_ts ? Number(event.start_ts) : null, + end_ts: event.end_ts ? Number(event.end_ts) : null, + duration_ms: event.duration_ms || null, + status: event.level === 'error' ? 'error' : 'ok', + attributes: {}, + children: [], + }; + spanMap.set(spanId, node); + } + } + + // Build tree structure by linking children to parents + for (const [spanId, node] of spanMap) { + if (node.parent_span_id && spanMap.has(node.parent_span_id)) { + // Add as child to parent + spanMap.get(node.parent_span_id)!.children.push(node); + } else if (!node.parent_span_id) { + // Root span (no parent) + rootSpans.push(node); + } + } + + // Build traces array + const traces = Array.from(traceCounts.entries()).map(([trace_id, span_count]) => ({ + trace_id, + span_count, + })); + + const response: SpanDagResponse = { + roots: rootSpans, + totalSpans: spanMap.size, + traces, + }; + + res.json(response); + } catch (error) { + console.error('Error fetching span DAG:', error); + res.status(500).json({ + error: 'Failed to fetch span DAG', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + // Serve static frontend files const staticPath = join(__dirname, 'public'); app.use(express.static(staticPath));