feat(web): add /api/spans/dag endpoint for OTLP span visualization
Some checks are pending
CI / test (18.x) (push) Waiting to run
CI / test (20.x) (push) Waiting to run
CI / test (22.x) (push) Waiting to run

Implements the missing /api/spans/dag endpoint that was blocking the
SpanDag component. The endpoint queries span events from the store and
builds a hierarchical tree structure for visualization.

Changes:
- Added GET /api/spans/dag endpoint in src/web/server.ts
- Added SpanDagResponse interface to src/types.ts for JSON serialization
- Updated SpanNode interface to use nullable fields (null instead of undefined)
- Fixed src/dagUtils.ts to use nullable SpanNode fields

The endpoint accepts an optional trace_id query parameter to filter
spans by trace, and returns a SpanDagResponse with root spans, total
span count, and trace summary.

Closes: bf-82u8
This commit is contained in:
jedarden 2026-05-26 19:41:13 -04:00
parent 1c1804371e
commit 7df43a353b
3 changed files with 119 additions and 18 deletions

View file

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

View file

@ -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<string, SpanNode[]>;
}
/**
* 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
// ============================================

View file

@ -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<string, SpanNode>();
const rootSpans: SpanNode[] = [];
const traceCounts = new Map<string, number>();
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));