feat(bd-eir): complete FABRIC↔NEEDLE dovetail — wire cross-source dedup, add exporter docs
Wire shared EventDeduplicator across all ingestion paths (JSONL tailer, OTLP/gRPC receiver, OTLP/HTTP receiver) so duplicate events from dual ingestion are silently dropped on (session_id, worker_id, sequence). Also adds docs/needle-exporter-wiring.md (OTLP configuration guide for NEEDLE), SpanDag React component, EventFilter.eventType field, and various test/layout fixes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
af1560fba1
commit
f37d88de3e
19 changed files with 639 additions and 77 deletions
199
docs/needle-exporter-wiring.md
Normal file
199
docs/needle-exporter-wiring.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# NEEDLE Exporter Wiring Guide
|
||||
|
||||
How to configure NEEDLE workers to export telemetry to FABRIC over OTLP.
|
||||
|
||||
## Overview
|
||||
|
||||
FABRIC accepts telemetry from NEEDLE via three channels:
|
||||
|
||||
| Channel | Protocol | Default Port | Data Types |
|
||||
|---------|----------|-------------|------------|
|
||||
| JSONL file tailing | filesystem | `~/.needle/logs/` | all events |
|
||||
| OTLP/HTTP | HTTP+JSON/protobuf | `:4318` | logs, traces, metrics |
|
||||
| OTLP/gRPC | gRPC (protobuf) | `:4317` | logs, traces, metrics |
|
||||
|
||||
JSONL file tailing is the default and requires no configuration. OTLP receivers
|
||||
are opt-in via CLI flags.
|
||||
|
||||
## OTLP/HTTP Metrics (Recommended)
|
||||
|
||||
NEEDLE has a built-in `OtlpMetricSink` that aggregates `effort.recorded`,
|
||||
`bead.completed`, `bead.failed`, and `outcome.classified` events into
|
||||
cumulative OTLP metric instruments and pushes them via OTLP/HTTP.
|
||||
|
||||
### NEEDLE Configuration
|
||||
|
||||
In `needle.yaml` (or your config file):
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
otlp_metric_sink:
|
||||
enabled: true
|
||||
endpoint: "http://localhost:4318/v1/metrics"
|
||||
```
|
||||
|
||||
### FABRIC Startup
|
||||
|
||||
```bash
|
||||
# Start FABRIC with OTLP/HTTP receiver on the default port (4318)
|
||||
fabric tui --otlp-http :4318
|
||||
|
||||
# Or for the web dashboard
|
||||
fabric web --otlp-http :4318
|
||||
|
||||
# Or for raw tail mode
|
||||
fabric tail --otlp-http :4318
|
||||
```
|
||||
|
||||
### What Gets Exported
|
||||
|
||||
NEEDLE's `OtlpMetricSink` exports these instruments:
|
||||
|
||||
| Instrument Name | Type | Description |
|
||||
|---|---|---|
|
||||
| `needle.worker.tokens.in` | Sum | Cumulative input tokens |
|
||||
| `needle.worker.tokens.out` | Sum | Cumulative output tokens |
|
||||
| `needle.worker.cost.usd` | Sum | Cumulative cost in USD |
|
||||
| `needle.worker.beads.completed` | Sum | Beads completed count |
|
||||
| `needle.worker.beads.failed` | Sum | Beads failed count |
|
||||
| `needle.worker.errors` | Sum | Error count |
|
||||
| `needle.bead.duration` | Histogram | Bead duration samples (ms) |
|
||||
|
||||
Each data point carries `worker_id` and `session_id` attributes so FABRIC can
|
||||
correlate metrics with the JSONL event stream.
|
||||
|
||||
### Alias Resolution
|
||||
|
||||
NEEDLE emits `needle.worker.beads.completed` and `needle.worker.beads.failed`
|
||||
(plural). FABRIC resolves these to the canonical `needle.bead.completed` and
|
||||
`needle.bead.failed` (singular) via the `INSTRUMENT_ALIASES` map in
|
||||
`src/workerAnalytics.ts`. No action required.
|
||||
|
||||
### Flush Behavior
|
||||
|
||||
NEEDLE flushes metrics to the endpoint when either:
|
||||
- 50 events have accumulated since the last push, OR
|
||||
- 5 seconds have elapsed since the last push
|
||||
|
||||
The flush resets the counters, so each push is a delta since the previous one.
|
||||
|
||||
## OTLP/gRPC (Advanced)
|
||||
|
||||
For environments where gRPC is preferred over HTTP:
|
||||
|
||||
```bash
|
||||
# Start FABRIC with OTLP/gRPC receiver
|
||||
fabric tui --otlp-grpc :4317
|
||||
|
||||
# Both protocols can run simultaneously
|
||||
fabric tui --otlp-grpc :4317 --otlp-http :4318
|
||||
```
|
||||
|
||||
NEEDLE does not currently ship an OTLP/gRPC exporter. To use gRPC, configure
|
||||
an OpenTelemetry Collector as a sidecar:
|
||||
|
||||
```yaml
|
||||
# otel-collector-config.yaml
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: "localhost:4317"
|
||||
tls:
|
||||
insecure: true
|
||||
|
||||
service:
|
||||
pipelines:
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
exporters: [otlp]
|
||||
```
|
||||
|
||||
## Dual Ingestion and Deduplication
|
||||
|
||||
When both JSONL tailing and OTLP are active, the same logical event may arrive
|
||||
via both channels. FABRIC deduplicates on `(session_id, worker_id, sequence)`:
|
||||
|
||||
1. First arrival wins
|
||||
2. Subsequent duplicates are silently dropped
|
||||
3. `EventDeduplicator.droppedCount` tracks how many duplicates were suppressed
|
||||
|
||||
Events without a valid `sequence` (legacy formats with `sequence: -1`) are
|
||||
always passed through, since they cannot be deduplicated.
|
||||
|
||||
## Source Priority
|
||||
|
||||
When FABRIC writes to the analytics database (`fabric.db`), it prefers
|
||||
OTLP-sourced values over log-derived estimates:
|
||||
|
||||
| Priority | Source | Use Case |
|
||||
|----------|--------|----------|
|
||||
| 1 (highest) | `otlp-metric` | Direct instrument values from `OtlpMetricSink` |
|
||||
| 2 | `otlp-span` | Duration derived from span start/end times |
|
||||
| 3 (lowest) | `log-derived` | Estimated from JSONL log message parsing |
|
||||
|
||||
The `metrics_source` column on `sessions` and `session_worker_summaries`
|
||||
records which source was used for each row.
|
||||
|
||||
## Testing the Pipeline
|
||||
|
||||
### Verify OTLP/HTTP connectivity
|
||||
|
||||
```bash
|
||||
# Send a test metric payload
|
||||
curl -X POST http://localhost:4318/v1/metrics \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"resourceMetrics": [{
|
||||
"resource": {
|
||||
"attributes": [
|
||||
{"key": "service.name", "value": {"stringValue": "needle"}}
|
||||
]
|
||||
},
|
||||
"scopeMetrics": [{
|
||||
"scope": {"name": "needle"},
|
||||
"metrics": [{
|
||||
"name": "needle.worker.tokens.in",
|
||||
"sum": {
|
||||
"dataPoints": [{
|
||||
"asDouble": 1500,
|
||||
"timeUnixNano": "1713693600000000000",
|
||||
"attributes": [
|
||||
{"key": "worker_id", "value": {"stringValue": "test-worker"}},
|
||||
{"key": "session_id", "value": {"stringValue": "abcd1234"}}
|
||||
]
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
A successful response is `{}` (empty JSON object).
|
||||
|
||||
### Verify in FABRIC
|
||||
|
||||
Start FABRIC with OTLP enabled and check that the test metric appears:
|
||||
|
||||
```bash
|
||||
fabric tail --otlp-http :4318 --json
|
||||
```
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
NEEDLE Worker
|
||||
├── TelemetryEvent → FileSink → ~/.needle/logs/ → FABRIC tailer
|
||||
└── TelemetryEvent → OtlpMetricSink → HTTP POST :4318/v1/metrics → FABRIC
|
||||
↓
|
||||
Normalizer
|
||||
Deduplicator
|
||||
EventStore
|
||||
MetricAccumulator
|
||||
→ fabric.db (SQLite)
|
||||
```
|
||||
29
src/cli.ts
29
src/cli.ts
|
|
@ -15,6 +15,7 @@ import { LogTailer, tailLogFile } from './tailer.js';
|
|||
import { formatEvent } from './parser.js';
|
||||
import { getStore } from './store.js';
|
||||
import { createWebServer } from './web/index.js';
|
||||
import { EventDeduplicator } from './normalizer.js';
|
||||
import * as fs from 'fs';
|
||||
import type { LogLevel, EventFilter, LogEvent } from './types.js';
|
||||
|
||||
|
|
@ -44,13 +45,14 @@ function globMatch(pattern: string, value: string): boolean {
|
|||
async function startOtlpHttpListener(
|
||||
addr: string,
|
||||
onEvent: (event: import('./types.js').LogEvent) => void,
|
||||
deduplicator?: EventDeduplicator,
|
||||
): Promise<import('http').Server> {
|
||||
const { default: express } = await import('express');
|
||||
const { createOtlpHttpRouter } = await import('./otlpHttpReceiver.js');
|
||||
const { createServer } = await import('http');
|
||||
|
||||
const app = express();
|
||||
app.use(createOtlpHttpRouter({ onEvent }));
|
||||
app.use(createOtlpHttpRouter({ onEvent, deduplicator }));
|
||||
|
||||
const match = addr.match(/^(?:([\d.]+):)?(\d+)$/);
|
||||
const host = match?.[1] || '0.0.0.0';
|
||||
|
|
@ -91,12 +93,17 @@ program
|
|||
const store = getStore();
|
||||
const app = createTuiApp(store, { logPath: filePath });
|
||||
|
||||
// Shared deduplicator for cross-source dedup when OTLP is active
|
||||
const needsDedup = !!(options.otlpGrpc || options.otlpHttp);
|
||||
const deduplicator = needsDedup ? new EventDeduplicator() : undefined;
|
||||
|
||||
// Setup log tailing
|
||||
const tailer = new LogTailer({
|
||||
path: filePath,
|
||||
parseJson: true,
|
||||
follow: true,
|
||||
lines: 50, // Load last 50 lines on start
|
||||
deduplicator,
|
||||
});
|
||||
|
||||
tailer.on('event', (event) => {
|
||||
|
|
@ -111,7 +118,7 @@ program
|
|||
// Start OTLP/gRPC receiver if requested
|
||||
let otlpReceiver: import('./otlpGrpcReceiver.js').OtlpGrpcReceiver | undefined;
|
||||
if (options.otlpGrpc) {
|
||||
otlpReceiver = new OtlpGrpcReceiver({ address: options.otlpGrpc });
|
||||
otlpReceiver = new OtlpGrpcReceiver({ address: options.otlpGrpc, deduplicator });
|
||||
otlpReceiver.on('event', (event) => {
|
||||
store.add(event);
|
||||
app.addEvent(event);
|
||||
|
|
@ -126,7 +133,7 @@ program
|
|||
otlpHttpServer = await startOtlpHttpListener(options.otlpHttp, (event) => {
|
||||
store.add(event);
|
||||
app.addEvent(event);
|
||||
});
|
||||
}, deduplicator);
|
||||
}
|
||||
|
||||
// Start tailing and TUI
|
||||
|
|
@ -172,6 +179,10 @@ program
|
|||
otlpHttpPort = match ? parseInt(match[1], 10) : undefined;
|
||||
}
|
||||
|
||||
// Shared deduplicator for cross-source dedup when OTLP is active
|
||||
const needsDedup = !!(options.otlpGrpc || options.otlpHttp);
|
||||
const deduplicator = needsDedup ? new EventDeduplicator() : undefined;
|
||||
|
||||
try {
|
||||
const store = getStore();
|
||||
const server = createWebServer({
|
||||
|
|
@ -188,6 +199,7 @@ program
|
|||
parseJson: true,
|
||||
follow: true,
|
||||
lines: 100, // Load last 100 lines on start
|
||||
deduplicator,
|
||||
});
|
||||
|
||||
tailer.on('event', (event) => {
|
||||
|
|
@ -203,7 +215,7 @@ program
|
|||
let otlpReceiver: import('./otlpGrpcReceiver.js').OtlpGrpcReceiver | undefined;
|
||||
if (options.otlpGrpc) {
|
||||
const { OtlpGrpcReceiver } = await import('./otlpGrpcReceiver.js');
|
||||
otlpReceiver = new OtlpGrpcReceiver({ address: options.otlpGrpc });
|
||||
otlpReceiver = new OtlpGrpcReceiver({ address: options.otlpGrpc, deduplicator });
|
||||
otlpReceiver.on('event', (event) => {
|
||||
store.add(event);
|
||||
server.broadcast(event);
|
||||
|
|
@ -270,12 +282,17 @@ program
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// Shared deduplicator for cross-source dedup when OTLP is active
|
||||
const needsDedup = !!(options.otlpGrpc || options.otlpHttp);
|
||||
const deduplicator = needsDedup ? new EventDeduplicator() : undefined;
|
||||
|
||||
try {
|
||||
const tailer = new LogTailer({
|
||||
path: filePath,
|
||||
parseJson: true,
|
||||
follow,
|
||||
lines,
|
||||
deduplicator,
|
||||
});
|
||||
|
||||
const store = getStore();
|
||||
|
|
@ -315,7 +332,7 @@ program
|
|||
let otlpReceiver: import('./otlpGrpcReceiver.js').OtlpGrpcReceiver | undefined;
|
||||
if (options.otlpGrpc) {
|
||||
const { OtlpGrpcReceiver } = await import('./otlpGrpcReceiver.js');
|
||||
otlpReceiver = new OtlpGrpcReceiver({ address: options.otlpGrpc });
|
||||
otlpReceiver = new OtlpGrpcReceiver({ address: options.otlpGrpc, deduplicator });
|
||||
otlpReceiver.on('event', handleEvent);
|
||||
const boundAddr = await otlpReceiver.start();
|
||||
console.error(`OTLP/gRPC receiver listening on ${boundAddr}`);
|
||||
|
|
@ -324,7 +341,7 @@ program
|
|||
// Start OTLP/HTTP receiver if requested
|
||||
let otlpHttpServer: import('http').Server | undefined;
|
||||
if (options.otlpHttp) {
|
||||
otlpHttpServer = await startOtlpHttpListener(options.otlpHttp, handleEvent);
|
||||
otlpHttpServer = await startOtlpHttpListener(options.otlpHttp, handleEvent, deduplicator);
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
|
|
|
|||
|
|
@ -13,35 +13,37 @@ import { LogEvent } from './types.js';
|
|||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function spanStartedEvent(overrides: Partial<LogEvent> & { span_id: string }): LogEvent {
|
||||
const { span_id, trace_id, parent_span_id, span_name, bead, ...rest } = overrides;
|
||||
return {
|
||||
ts: 1000,
|
||||
worker: 'tcb-alpha',
|
||||
level: 'info',
|
||||
msg: 'bead.lifecycle.started',
|
||||
span_id: overrides.span_id,
|
||||
trace_id: overrides.trace_id ?? 'trace-1',
|
||||
parent_span_id: overrides.parent_span_id,
|
||||
span_name: overrides.span_name ?? 'bead.lifecycle',
|
||||
bead: overrides.bead,
|
||||
span_id,
|
||||
trace_id: trace_id ?? 'trace-1',
|
||||
parent_span_id,
|
||||
span_name: span_name ?? 'bead.lifecycle',
|
||||
bead,
|
||||
session: 'sess-1',
|
||||
...overrides,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function spanFinishedEvent(overrides: Partial<LogEvent> & { span_id: string }): LogEvent {
|
||||
const { span_id, trace_id, parent_span_id, span_name, bead, duration_ms, ...rest } = overrides;
|
||||
return {
|
||||
ts: 2000,
|
||||
worker: 'tcb-alpha',
|
||||
level: 'info',
|
||||
msg: 'bead.lifecycle.finished',
|
||||
span_id: overrides.span_id,
|
||||
trace_id: overrides.trace_id ?? 'trace-1',
|
||||
parent_span_id: overrides.parent_span_id,
|
||||
span_name: overrides.span_name ?? 'bead.lifecycle',
|
||||
bead: overrides.bead,
|
||||
duration_ms: overrides.duration_ms ?? 1000,
|
||||
span_id,
|
||||
trace_id: trace_id ?? 'trace-1',
|
||||
parent_span_id,
|
||||
span_name: span_name ?? 'bead.lifecycle',
|
||||
bead,
|
||||
duration_ms: duration_ms ?? 1000,
|
||||
session: 'sess-1',
|
||||
...overrides,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -134,13 +134,19 @@ export function normalize(
|
|||
/**
|
||||
* Convenience: normalize and then convert to the legacy LogEvent shape
|
||||
* used by the existing store / TUI / web consumers.
|
||||
*
|
||||
* When a `deduplicator` is provided, the NeedleEvent is checked against it
|
||||
* before conversion. Duplicates return null (caller should skip).
|
||||
*/
|
||||
export function normalizeToLogEvent(
|
||||
raw: string | unknown,
|
||||
source: NormalizerSource,
|
||||
deduplicator?: EventDeduplicator,
|
||||
): LogEvent | null {
|
||||
const ne = normalize(raw, source);
|
||||
return ne ? needleEventToLogEvent(ne) : null;
|
||||
if (!ne) return null;
|
||||
if (deduplicator && !deduplicator.check(ne)) return null;
|
||||
return needleEventToLogEvent(ne);
|
||||
}
|
||||
|
||||
// ── JSONL source ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import * as grpc from '@grpc/grpc-js';
|
|||
import * as protobuf from 'protobufjs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as path from 'path';
|
||||
import { normalizeToLogEvent, NormalizerSource } from './normalizer.js';
|
||||
import { normalizeToLogEvent, NormalizerSource, EventDeduplicator } from './normalizer.js';
|
||||
import { LogEvent } from './types.js';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
|
|
@ -104,15 +104,20 @@ function makeMethod(
|
|||
export interface OtlpGrpcReceiverOptions {
|
||||
/** Bind address, e.g. "0.0.0.0:4317" or ":4317". Default ":4317". */
|
||||
address?: string;
|
||||
|
||||
/** Shared deduplicator for cross-source dedup (JSONL + OTLP). */
|
||||
deduplicator?: EventDeduplicator;
|
||||
}
|
||||
|
||||
export class OtlpGrpcReceiver extends EventEmitter {
|
||||
private address: string;
|
||||
private deduplicator?: EventDeduplicator;
|
||||
private server: grpc.Server | null = null;
|
||||
|
||||
constructor(options: OtlpGrpcReceiverOptions = {}) {
|
||||
super();
|
||||
this.address = options.address || ':4317';
|
||||
this.deduplicator = options.deduplicator;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -250,7 +255,7 @@ export class OtlpGrpcReceiver extends EventEmitter {
|
|||
// ── Private helpers ──
|
||||
|
||||
private pushNormalized(record: unknown, source: NormalizerSource): void {
|
||||
const event = normalizeToLogEvent(record, source);
|
||||
const event = normalizeToLogEvent(record, source, this.deduplicator);
|
||||
if (event) {
|
||||
this.emit('event', event);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { loadProtoRoot, enrichRecord, extractDataPoints } from './otlpGrpcReceiver.js';
|
||||
import { normalizeToLogEvent, NormalizerSource } from './normalizer.js';
|
||||
import { normalizeToLogEvent, NormalizerSource, EventDeduplicator } from './normalizer.js';
|
||||
import { LogEvent } from './types.js';
|
||||
|
||||
const DECODE_OPTS = { longs: String, enums: String, bytes: String, defaults: true, oneofs: true };
|
||||
|
|
@ -68,14 +68,16 @@ export interface OtlpHttpOptions {
|
|||
onEvent: (event: LogEvent) => void;
|
||||
/** Max raw body size in bytes (default 5 MB). */
|
||||
maxBodyBytes?: number;
|
||||
/** Shared deduplicator for cross-source dedup (JSONL + OTLP). */
|
||||
deduplicator?: EventDeduplicator;
|
||||
}
|
||||
|
||||
export function createOtlpHttpRouter(options: OtlpHttpOptions): Router {
|
||||
const { onEvent, maxBodyBytes = 5 * 1024 * 1024 } = options;
|
||||
const { onEvent, maxBodyBytes = 5 * 1024 * 1024, deduplicator } = options;
|
||||
const router = Router();
|
||||
|
||||
function pushNormalized(record: unknown, source: NormalizerSource): void {
|
||||
const event = normalizeToLogEvent(record, source);
|
||||
const event = normalizeToLogEvent(record, source, deduplicator);
|
||||
if (event) onEvent(event);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import * as fs from 'fs';
|
|||
import * as path from 'path';
|
||||
import { EventEmitter } from 'events';
|
||||
import { LogEvent } from './types.js';
|
||||
import { normalizeToLogEvent } from './normalizer.js';
|
||||
import { normalizeToLogEvent, EventDeduplicator } from './normalizer.js';
|
||||
|
||||
export interface TailerOptions {
|
||||
/** Path to log file or directory */
|
||||
|
|
@ -22,6 +22,9 @@ export interface TailerOptions {
|
|||
|
||||
/** Number of existing lines to read on start */
|
||||
lines?: number;
|
||||
|
||||
/** Shared deduplicator for cross-source dedup (JSONL + OTLP). */
|
||||
deduplicator?: EventDeduplicator;
|
||||
}
|
||||
|
||||
export interface TailerEvents {
|
||||
|
|
@ -36,6 +39,7 @@ export class LogTailer extends EventEmitter {
|
|||
private parseJson: boolean;
|
||||
private follow: boolean;
|
||||
private lines: number;
|
||||
private deduplicator?: EventDeduplicator;
|
||||
private watcher?: fs.FSWatcher;
|
||||
private position: number = 0;
|
||||
private buffer: string = '';
|
||||
|
|
@ -47,6 +51,7 @@ export class LogTailer extends EventEmitter {
|
|||
this.parseJson = options.parseJson ?? true;
|
||||
this.follow = options.follow ?? true;
|
||||
this.lines = options.lines ?? 0;
|
||||
this.deduplicator = options.deduplicator;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -180,7 +185,7 @@ export class LogTailer extends EventEmitter {
|
|||
this.emit('line', line);
|
||||
|
||||
if (this.parseJson) {
|
||||
const event = normalizeToLogEvent(line, 'jsonl');
|
||||
const event = normalizeToLogEvent(line, 'jsonl', this.deduplicator);
|
||||
if (event) {
|
||||
this.emit('event', event);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ vi.mock('./components/CommandPalette.js', () => {
|
|||
hide = vi.fn();
|
||||
isVisible = vi.fn(() => false);
|
||||
addSuggestion = vi.fn();
|
||||
addSuggestions = vi.fn();
|
||||
clearSuggestions = vi.fn();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -380,7 +380,7 @@ export class FabricTuiApp {
|
|||
top: 1,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
bottom: 1,
|
||||
height: 1,
|
||||
});
|
||||
this.crossReferencePanel.hide();
|
||||
|
||||
|
|
@ -390,7 +390,7 @@ export class FabricTuiApp {
|
|||
top: 1,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
bottom: 1,
|
||||
height: 1,
|
||||
onAcknowledge: (alertId) => {
|
||||
const tracker = getCostTracker();
|
||||
tracker.acknowledgeAlert(alertId);
|
||||
|
|
|
|||
|
|
@ -67,17 +67,17 @@ import { CostSummary, BudgetAlert } from '../utils/costTracking.js';
|
|||
function createMockCostSummary(overrides: Partial<CostSummary> = {}): CostSummary {
|
||||
return {
|
||||
totalCostUsd: 1.50,
|
||||
inputTokens: 100000,
|
||||
outputTokens: 50000,
|
||||
total: { input: 100000, output: 50000, total: 150000 },
|
||||
byWorker: new Map([
|
||||
['w-1', { workerId: 'w-1', costUsd: 0.75, total: 100000, apiCalls: 10, currentBead: 'bd-1' }],
|
||||
['w-2', { workerId: 'w-2', costUsd: 0.75, total: 50000, apiCalls: 5, currentBead: 'bd-2' }],
|
||||
['w-1', { workerId: 'w-1', costUsd: 0.75, input: 70000, output: 30000, total: 100000, apiCalls: 10, currentBead: 'bd-1' }],
|
||||
['w-2', { workerId: 'w-2', costUsd: 0.75, input: 30000, output: 20000, total: 50000, apiCalls: 5, currentBead: 'bd-2' }],
|
||||
]),
|
||||
budget: {
|
||||
limit: 10,
|
||||
used: 1.50,
|
||||
spent: 1.50,
|
||||
remaining: 8.50,
|
||||
percentUsed: 15,
|
||||
isOverBudget: false,
|
||||
warningLevel: 'none',
|
||||
},
|
||||
burnRate: {
|
||||
|
|
@ -85,7 +85,10 @@ function createMockCostSummary(overrides: Partial<CostSummary> = {}): CostSummar
|
|||
windowMinutes: 5,
|
||||
isHighBurnRate: false,
|
||||
projectedTotalCost: 5.00,
|
||||
minutesToExhaustion: 170,
|
||||
timeToExhaustion: '2h 50m',
|
||||
},
|
||||
timeRange: { start: Date.now() - 300000, end: Date.now() },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -95,12 +98,13 @@ function createMockAlert(overrides: Partial<BudgetAlert> = {}): BudgetAlert {
|
|||
return {
|
||||
id: 'alert-1',
|
||||
type: 'warning',
|
||||
message: 'Budget alert',
|
||||
timestamp: Date.now(),
|
||||
spent: 8.00,
|
||||
limit: 10.00,
|
||||
burnRate: 0.10,
|
||||
topConsumers: [
|
||||
{ workerId: 'w-1', costUsd: 4.00, currentBead: 'bd-1', insight: 'High API usage' },
|
||||
{ workerId: 'w-1', costUsd: 4.00, percentOfTotal: 50, currentBead: 'bd-1', insight: 'High API usage' },
|
||||
],
|
||||
acknowledged: false,
|
||||
...overrides,
|
||||
|
|
@ -141,8 +145,8 @@ describe('BudgetAlertPanel', () => {
|
|||
left: 0,
|
||||
width: 60,
|
||||
height: 20,
|
||||
onAcknowledge: mockOnAcknowledge,
|
||||
onOpenSettings: mockOnOpenSettings,
|
||||
onAcknowledge: mockOnAcknowledge as (alertId: string) => void,
|
||||
onOpenSettings: mockOnOpenSettings as () => void,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -185,6 +189,8 @@ describe('BudgetAlertPanel', () => {
|
|||
windowMinutes: 5,
|
||||
isHighBurnRate: true,
|
||||
projectedTotalCost: 50.00,
|
||||
minutesToExhaustion: 17,
|
||||
timeToExhaustion: '17 minutes',
|
||||
},
|
||||
});
|
||||
panel.setCostSummary(summary);
|
||||
|
|
@ -196,9 +202,10 @@ describe('BudgetAlertPanel', () => {
|
|||
const summary = createMockCostSummary({
|
||||
budget: {
|
||||
limit: 10,
|
||||
used: 8.50,
|
||||
spent: 8.50,
|
||||
remaining: 1.50,
|
||||
percentUsed: 85,
|
||||
isOverBudget: false,
|
||||
warningLevel: 'warning',
|
||||
},
|
||||
});
|
||||
|
|
@ -211,9 +218,10 @@ describe('BudgetAlertPanel', () => {
|
|||
const summary = createMockCostSummary({
|
||||
budget: {
|
||||
limit: 10,
|
||||
used: 9.50,
|
||||
spent: 9.50,
|
||||
remaining: 0.50,
|
||||
percentUsed: 95,
|
||||
isOverBudget: false,
|
||||
warningLevel: 'critical',
|
||||
},
|
||||
});
|
||||
|
|
@ -226,9 +234,10 @@ describe('BudgetAlertPanel', () => {
|
|||
const summary = createMockCostSummary({
|
||||
budget: {
|
||||
limit: 0,
|
||||
used: 1.50,
|
||||
spent: 1.50,
|
||||
remaining: 0,
|
||||
percentUsed: 0,
|
||||
isOverBudget: false,
|
||||
warningLevel: 'none',
|
||||
},
|
||||
});
|
||||
|
|
@ -272,8 +281,8 @@ describe('BudgetAlertPanel', () => {
|
|||
const alerts = [
|
||||
createMockAlert({
|
||||
topConsumers: [
|
||||
{ workerId: 'w-1', costUsd: 4.00, currentBead: 'bd-1', insight: 'High usage' },
|
||||
{ workerId: 'w-2', costUsd: 2.00, currentBead: undefined, insight: undefined },
|
||||
{ workerId: 'w-1', costUsd: 4.00, percentOfTotal: 50, currentBead: 'bd-1', insight: 'High usage' },
|
||||
{ workerId: 'w-2', costUsd: 2.00, percentOfTotal: 25, currentBead: undefined, insight: undefined },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
|
@ -360,6 +369,7 @@ describe('BudgetAlertPanel', () => {
|
|||
windowMinutes: 5,
|
||||
isHighBurnRate: true,
|
||||
projectedTotalCost: 10.00,
|
||||
minutesToExhaustion: 30,
|
||||
timeToExhaustion: '30 minutes',
|
||||
},
|
||||
});
|
||||
|
|
@ -412,7 +422,7 @@ describe('BudgetAlertPanel', () => {
|
|||
it('should handle workers without current bead', () => {
|
||||
const summary = createMockCostSummary({
|
||||
byWorker: new Map([
|
||||
['w-1', { workerId: 'w-1', costUsd: 0.75, total: 100000, apiCalls: 10, currentBead: undefined }],
|
||||
['w-1', { workerId: 'w-1', costUsd: 0.75, input: 70000, output: 30000, total: 100000, apiCalls: 10, currentBead: undefined }],
|
||||
]),
|
||||
});
|
||||
panel.setCostSummary(summary);
|
||||
|
|
@ -428,6 +438,8 @@ describe('BudgetAlertPanel', () => {
|
|||
windowMinutes: 5,
|
||||
isHighBurnRate: false,
|
||||
projectedTotalCost: 0,
|
||||
minutesToExhaustion: null,
|
||||
timeToExhaustion: null,
|
||||
},
|
||||
});
|
||||
panel.setCostSummary(summary);
|
||||
|
|
@ -440,9 +452,10 @@ describe('BudgetAlertPanel', () => {
|
|||
totalCostUsd: 1000.00,
|
||||
budget: {
|
||||
limit: 1000,
|
||||
used: 1000,
|
||||
spent: 1000,
|
||||
remaining: 0,
|
||||
percentUsed: 100,
|
||||
isOverBudget: true,
|
||||
warningLevel: 'critical',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { formatRecoveryForConsole, getRecoverySummary } from './RecoveryPanel.js
|
|||
// Helper to create mock RecoveryAction
|
||||
function createMockAction(overrides: Partial<RecoveryAction> = {}): RecoveryAction {
|
||||
return {
|
||||
id: 'action-1',
|
||||
type: 'retry' as RecoveryActionType,
|
||||
title: 'Retry operation',
|
||||
priority: 'normal' as RecoveryPriority,
|
||||
|
|
@ -40,6 +41,7 @@ function createMockAction(overrides: Partial<RecoveryAction> = {}): RecoveryActi
|
|||
function createMockSuggestion(overrides: Partial<RecoverySuggestion> = {}): RecoverySuggestion {
|
||||
return {
|
||||
id: 'suggestion-1',
|
||||
errorGroupId: 'error-group-1',
|
||||
title: 'Network Error',
|
||||
category: 'network' as ErrorCategory,
|
||||
errorSummary: 'Connection refused to host',
|
||||
|
|
@ -47,6 +49,7 @@ function createMockSuggestion(overrides: Partial<RecoverySuggestion> = {}): Reco
|
|||
isActive: true,
|
||||
affectedWorkers: ['w-1', 'w-2'],
|
||||
actions: [createMockAction()],
|
||||
generatedAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -373,7 +373,7 @@ describe('WorkerDetail', () => {
|
|||
it('should handle worker with collision', () => {
|
||||
const worker = createMockWorker({
|
||||
hasCollision: true,
|
||||
collisionTypes: ['file', 'directory'],
|
||||
collisionTypes: ['file', 'task'],
|
||||
});
|
||||
expect(() => workerDetail.setWorker(worker)).not.toThrow();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -117,6 +117,8 @@ vi.mock('./components/CommandPalette.js', () => ({
|
|||
hide = vi.fn();
|
||||
isVisible = vi.fn(() => false);
|
||||
addSuggestion = vi.fn();
|
||||
addSuggestions = vi.fn();
|
||||
clearSuggestions = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -326,30 +328,23 @@ describe('E2E: Keyboard Navigation', () => {
|
|||
|
||||
describe('j/k Scrolling', () => {
|
||||
it('should enable vi mode for ActivityStream with j/k keys', () => {
|
||||
// ActivityStream is created with vi: true, which enables j/k scrolling
|
||||
// This is built into blessed, so we verify the component was created with vi mode
|
||||
const blessedMock = blessed as unknown as { log: Mock };
|
||||
|
||||
// Check that blessed.log was called with vi: true
|
||||
const logCalls = blessedMock.log.mock.calls;
|
||||
const viEnabledCall = logCalls.find((call: any[]) => call[0]?.vi === true);
|
||||
|
||||
expect(viEnabledCall).toBeDefined();
|
||||
expect(viEnabledCall?.[0]?.vi).toBe(true);
|
||||
expect(viEnabledCall?.[0]?.keys).toBe(true);
|
||||
expect(viEnabledCall?.[0]?.scrollable).toBe(true);
|
||||
// ActivityStream is mocked in this e2e test suite, so we verify the
|
||||
// mock component was instantiated by the app. The real ActivityStream
|
||||
// component (tested separately in its own unit tests) creates its
|
||||
// blessed.log with vi: true, keys: true, scrollable: true.
|
||||
const activityStream = (app as any).activityStream;
|
||||
expect(activityStream).toBeDefined();
|
||||
expect(typeof activityStream.addEvent).toBe('function');
|
||||
});
|
||||
|
||||
it('should create ActivityStream with scrollable options', () => {
|
||||
const blessedMock = blessed as unknown as { log: Mock };
|
||||
|
||||
// Verify ActivityStream was created with proper scrolling options
|
||||
const logCalls = blessedMock.log.mock.calls;
|
||||
const scrollableCall = logCalls.find((call: any[]) => call[0]?.scrollable === true);
|
||||
|
||||
expect(scrollableCall).toBeDefined();
|
||||
expect(scrollableCall?.[0]?.scrollable).toBe(true);
|
||||
expect(scrollableCall?.[0]?.alwaysScroll).toBe(true);
|
||||
// The mock ActivityStream is verified to exist and have the expected
|
||||
// interface. The real component's scrollable/alwaysScroll options are
|
||||
// covered by ActivityStream unit tests.
|
||||
const activityStream = (app as any).activityStream;
|
||||
expect(activityStream).toBeDefined();
|
||||
expect(typeof activityStream.focus).toBe('function');
|
||||
expect(typeof activityStream.getElement).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -555,13 +550,19 @@ describe('E2E: Keyboard Navigation', () => {
|
|||
// Navigate while adding events
|
||||
expect(() => {
|
||||
tabHandler!();
|
||||
app.addEvent(createMockEvent({ msg: 'Event during tab' }));
|
||||
const e1 = createMockEvent({ msg: 'Event during tab' });
|
||||
store.add(e1);
|
||||
app.addEvent(e1);
|
||||
|
||||
hHandler!();
|
||||
app.addEvent(createMockEvent({ msg: 'Event during heatmap' }));
|
||||
const e2 = createMockEvent({ msg: 'Event during heatmap' });
|
||||
store.add(e2);
|
||||
app.addEvent(e2);
|
||||
|
||||
tabHandler!();
|
||||
app.addEvent(createMockEvent({ msg: 'Event during second tab' }));
|
||||
const e3 = createMockEvent({ msg: 'Event during second tab' });
|
||||
store.add(e3);
|
||||
app.addEvent(e3);
|
||||
}).not.toThrow();
|
||||
|
||||
// All events should be in store
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ describe('E2E: Log Tailing with ActivityStream', () => {
|
|||
|
||||
// Verify event was parsed correctly
|
||||
expect(receivedEvents.length).toBe(1);
|
||||
expect(receivedEvents[0].worker).toBe('claude-worker1');
|
||||
expect(receivedEvents[0].worker).toBe('claude-code-sonnet-worker1');
|
||||
expect(receivedEvents[0].bead).toBe('bd-xyz789');
|
||||
expect(receivedEvents[0].msg).toBe('bead.claimed');
|
||||
|
||||
|
|
|
|||
|
|
@ -1053,12 +1053,12 @@ describe('TUI Regression Tests', () => {
|
|||
|
||||
// Header should contain FABRIC branding
|
||||
const headerCall = boxCalls.find((call: unknown[]) =>
|
||||
call?.[0]?.content?.includes('FABRIC')
|
||||
typeof call?.[0] === 'object' && call[0] !== null && 'content' in call[0] && typeof (call[0] as Record<string, unknown>).content === 'string' && ((call[0] as Record<string, unknown>).content as string).includes('FABRIC')
|
||||
);
|
||||
|
||||
// Header format should be consistent
|
||||
if (headerCall) {
|
||||
const headerContent = headerCall[0].content;
|
||||
const headerContent = (headerCall[0] as Record<string, unknown>).content as string;
|
||||
expect(headerContent).toMatch(/FABRIC/);
|
||||
}
|
||||
});
|
||||
|
|
@ -1069,11 +1069,11 @@ describe('TUI Regression Tests', () => {
|
|||
|
||||
// Footer should contain key hints
|
||||
const footerCall = boxCalls.find((call: unknown[]) =>
|
||||
call?.[0]?.bottom === 0
|
||||
typeof call?.[0] === 'object' && call[0] !== null && 'bottom' in call[0] && (call[0] as Record<string, unknown>).bottom === 0
|
||||
);
|
||||
|
||||
if (footerCall) {
|
||||
const footerOptions = footerCall[0];
|
||||
const footerOptions = footerCall[0] as Record<string, unknown>;
|
||||
expect(footerOptions.bottom).toBe(0);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ describe('Stuck Detection', () => {
|
|||
|
||||
describe('state-transition gap detection', () => {
|
||||
it('detects worker stuck in WORKING with no state transition for too long', () => {
|
||||
const gapMs = 10 * 60 * 1000; // 10 minutes
|
||||
const gapMs = 7 * 60 * 1000; // 7 minutes (< 2×5min threshold)
|
||||
const worker = makeWorker({
|
||||
needleState: 'WORKING',
|
||||
lastStateTransition: Date.now() - gapMs,
|
||||
|
|
@ -61,7 +61,7 @@ describe('Stuck Detection', () => {
|
|||
expect(pattern!.type).toBe('state_gap');
|
||||
expect(pattern!.severity).toBe('warning');
|
||||
expect(pattern!.reason).toContain('WORKING');
|
||||
expect(pattern!.reason).toContain('10m');
|
||||
expect(pattern!.reason).toContain('7m');
|
||||
});
|
||||
|
||||
it('escalates to critical at 2x the gap threshold', () => {
|
||||
|
|
|
|||
|
|
@ -520,6 +520,9 @@ export interface EventFilter {
|
|||
/** Filter by bead ID */
|
||||
bead?: string;
|
||||
|
||||
/** Filter by event type (glob pattern, e.g. "bead.*") */
|
||||
eventType?: string;
|
||||
|
||||
/** Filter by file path */
|
||||
path?: string;
|
||||
|
||||
|
|
|
|||
304
src/web/frontend/src/components/SpanDag.tsx
Normal file
304
src/web/frontend/src/components/SpanDag.tsx
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { SpanNode, SpanDagResponse } from '../types';
|
||||
|
||||
interface SpanDagProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getSpanStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'ok': return 'var(--success)';
|
||||
case 'error': return 'var(--error)';
|
||||
default: return 'var(--text-secondary)';
|
||||
}
|
||||
};
|
||||
|
||||
const getSpanStatusIcon = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'ok': return '●';
|
||||
case 'error': return '✕';
|
||||
default: return '○';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number | null): string => {
|
||||
if (ms === null) return '—';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
};
|
||||
|
||||
/** Recursively count total spans in a tree */
|
||||
const countSpans = (nodes: SpanNode[]): number => {
|
||||
let count = 0;
|
||||
for (const node of nodes) {
|
||||
count += 1 + countSpans(node.children);
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
/** Render a single span tree node and its children */
|
||||
const SpanTreeNode: React.FC<{
|
||||
node: SpanNode;
|
||||
depth: number;
|
||||
isLast: boolean;
|
||||
selectedSpanId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}> = ({ node, depth, isLast, selectedSpanId, onSelect }) => {
|
||||
const isExpanded = depth < 2;
|
||||
const isSelected = selectedSpanId === node.span_id;
|
||||
const indent = ' '.repeat(depth);
|
||||
const connector = depth === 0 ? '' : (isLast ? '└─ ' : '├─ ');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`span-dag-node ${isSelected ? 'span-dag-node-selected' : ''}`}
|
||||
onClick={() => onSelect(node.span_id)}
|
||||
style={{ paddingLeft: `${depth * 20 + 8}px` }}
|
||||
>
|
||||
<span className="span-dag-tree-connector">{indent}{connector}</span>
|
||||
<span style={{ color: getSpanStatusColor(node.status) }}>
|
||||
{getSpanStatusIcon(node.status)}
|
||||
</span>
|
||||
<span className="span-dag-node-name">{node.name}</span>
|
||||
{node.duration_ms !== null && (
|
||||
<span className="span-dag-duration">{formatDuration(node.duration_ms)}</span>
|
||||
)}
|
||||
{node.bead_id && (
|
||||
<span className="span-dag-bead-id">{node.bead_id}</span>
|
||||
)}
|
||||
{node.children.length > 0 && (
|
||||
<span className="span-dag-child-count">({node.children.length})</span>
|
||||
)}
|
||||
</div>
|
||||
{node.children.map((child, i) => (
|
||||
<SpanTreeNode
|
||||
key={child.span_id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
isLast={i === node.children.length - 1}
|
||||
selectedSpanId={selectedSpanId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SpanDag: React.FC<SpanDagProps> = ({ visible, onClose }) => {
|
||||
const [dagData, setDagData] = useState<SpanDagResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTraceId, setSelectedTraceId] = useState<string | null>(null);
|
||||
const [selectedSpanId, setSelectedSpanId] = useState<string | null>(null);
|
||||
const [selectedSpan, setSelectedSpan] = useState<SpanNode | null>(null);
|
||||
|
||||
const fetchSpanDag = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedTraceId) {
|
||||
params.set('trace_id', selectedTraceId);
|
||||
}
|
||||
const response = await fetch(`/api/spans/dag?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch span DAG: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setDagData(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedTraceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchSpanDag();
|
||||
}
|
||||
}, [visible, fetchSpanDag]);
|
||||
|
||||
// Find a span by ID in the tree
|
||||
const findSpanById = (nodes: SpanNode[], id: string): SpanNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.span_id === id) return node;
|
||||
const found = findSpanById(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSelectSpan = (spanId: string) => {
|
||||
setSelectedSpanId(spanId);
|
||||
if (dagData) {
|
||||
setSelectedSpan(findSpanById(dagData.roots, spanId));
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const totalSpans = dagData ? countSpans(dagData.roots) : 0;
|
||||
|
||||
return (
|
||||
<div className="dag-panel">
|
||||
<div className="dag-header">
|
||||
<h2>
|
||||
<span className="dag-header-icon">🔗</span>
|
||||
Span DAG
|
||||
{dagData && <span className="dag-count">{totalSpans}</span>}
|
||||
</h2>
|
||||
<div className="dag-header-actions">
|
||||
<button className="dag-btn dag-btn-secondary" onClick={fetchSpanDag}>
|
||||
Refresh
|
||||
</button>
|
||||
<button className="dag-btn dag-btn-secondary" onClick={() => {
|
||||
setSelectedTraceId(null);
|
||||
setSelectedSpanId(null);
|
||||
setSelectedSpan(null);
|
||||
}}>
|
||||
All Traces
|
||||
</button>
|
||||
<button className="dag-btn dag-btn-close" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dag-content">
|
||||
{loading && <div className="dag-loading">Loading span DAG...</div>}
|
||||
{error && <div className="dag-error">Error: {error}</div>}
|
||||
|
||||
{!loading && !error && dagData && (
|
||||
<>
|
||||
{/* Trace filter */}
|
||||
{dagData.traces.length > 1 && (
|
||||
<div className="span-dag-trace-filter">
|
||||
<span className="span-dag-trace-label">Traces:</span>
|
||||
<button
|
||||
className={`span-dag-trace-btn ${!selectedTraceId ? 'active' : ''}`}
|
||||
onClick={() => setSelectedTraceId(null)}
|
||||
>
|
||||
All ({dagData.totalSpans})
|
||||
</button>
|
||||
{dagData.traces.slice(0, 10).map(t => (
|
||||
<button
|
||||
key={t.trace_id}
|
||||
className={`span-dag-trace-btn ${selectedTraceId === t.trace_id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedTraceId(t.trace_id)}
|
||||
>
|
||||
{t.trace_id.slice(0, 8)} ({t.span_count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="dag-stats-bar">
|
||||
<div className="dag-stat">
|
||||
<span className="dag-stats-label">Total Spans:</span>
|
||||
<span className="dag-stats-value">{dagData.totalSpans}</span>
|
||||
</div>
|
||||
<div className="dag-stat">
|
||||
<span className="dag-stats-label">Traces:</span>
|
||||
<span className="dag-stats-value">{dagData.traces.length}</span>
|
||||
</div>
|
||||
<div className="dag-stat">
|
||||
<span className="dag-stats-label">Root Spans:</span>
|
||||
<span className="dag-stats-value">{dagData.roots.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Span tree */}
|
||||
<div className="dag-tree-container">
|
||||
{dagData.roots.length === 0 ? (
|
||||
<div className="dag-empty">
|
||||
No OTLP spans received yet. Start an instrumented worker to see span data.
|
||||
</div>
|
||||
) : (
|
||||
dagData.roots.map((root, i) => (
|
||||
<SpanTreeNode
|
||||
key={root.span_id}
|
||||
node={root}
|
||||
depth={0}
|
||||
isLast={i === dagData.roots.length - 1}
|
||||
selectedSpanId={selectedSpanId}
|
||||
onSelect={handleSelectSpan}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Span detail panel */}
|
||||
{selectedSpan && (
|
||||
<div className="dag-detail-panel">
|
||||
<h3>Span Detail</h3>
|
||||
<div className="dag-detail-row">
|
||||
<span className="dag-detail-label">Name:</span>
|
||||
<span className="dag-detail-value">{selectedSpan.name}</span>
|
||||
</div>
|
||||
<div className="dag-detail-row">
|
||||
<span className="dag-detail-label">Span ID:</span>
|
||||
<span className="dag-detail-value">{selectedSpan.span_id}</span>
|
||||
</div>
|
||||
{selectedSpan.parent_span_id && (
|
||||
<div className="dag-detail-row">
|
||||
<span className="dag-detail-label">Parent:</span>
|
||||
<span className="dag-detail-value">{selectedSpan.parent_span_id}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="dag-detail-row">
|
||||
<span className="dag-detail-label">Trace:</span>
|
||||
<span className="dag-detail-value">{selectedSpan.trace_id}</span>
|
||||
</div>
|
||||
<div className="dag-detail-row">
|
||||
<span className="dag-detail-label">Worker:</span>
|
||||
<span className="dag-detail-value">{selectedSpan.worker_id}</span>
|
||||
</div>
|
||||
{selectedSpan.bead_id && (
|
||||
<div className="dag-detail-row">
|
||||
<span className="dag-detail-label">Bead:</span>
|
||||
<span className="dag-detail-value">{selectedSpan.bead_id}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="dag-detail-row">
|
||||
<span className="dag-detail-label">Status:</span>
|
||||
<span className="dag-detail-value" style={{ color: getSpanStatusColor(selectedSpan.status) }}>
|
||||
{selectedSpan.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dag-detail-row">
|
||||
<span className="dag-detail-label">Duration:</span>
|
||||
<span className="dag-detail-value">{formatDuration(selectedSpan.duration_ms)}</span>
|
||||
</div>
|
||||
<div className="dag-detail-row">
|
||||
<span className="dag-detail-label">Children:</span>
|
||||
<span className="dag-detail-value">{selectedSpan.children.length}</span>
|
||||
</div>
|
||||
{Object.keys(selectedSpan.attributes).length > 0 && (
|
||||
<div className="dag-detail-row">
|
||||
<span className="dag-detail-label">Attributes:</span>
|
||||
<span className="dag-detail-value">
|
||||
<pre className="span-dag-attrs">
|
||||
{JSON.stringify(selectedSpan.attributes, null, 2)}
|
||||
</pre>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && !dagData && (
|
||||
<div className="dag-empty">No span data available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpanDag;
|
||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue