feat(web): add /api/spans/dag endpoint for OTLP span visualization
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:
parent
1c1804371e
commit
7df43a353b
3 changed files with 119 additions and 18 deletions
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
36
src/types.ts
36
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<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
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue