diff --git a/src/observability/index.ts b/src/observability/index.ts index aa74d550cd..5358f0186b 100644 --- a/src/observability/index.ts +++ b/src/observability/index.ts @@ -11,11 +11,16 @@ export { endSpan, extractContext, getActiveContext, + getSpanBuffer, initTracing, injectContext, isTracingEnabled, + resetSpanBuffer, setSpanAttributes, shutdownTracing, + SpanBuffer, + type SpanEntry, + type SpanFilter, SpanNames, type SpanOptions, startSpan, diff --git a/src/observability/tracing/exporters/console-exporter.test.ts b/src/observability/tracing/exporters/console-exporter.test.ts new file mode 100644 index 0000000000..7ec71321ea --- /dev/null +++ b/src/observability/tracing/exporters/console-exporter.test.ts @@ -0,0 +1,178 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { ConsoleSpanExporter, convertReadableSpan } from "./console-exporter.ts"; +import { getSpanBuffer, resetSpanBuffer } from "../span-buffer.ts"; + +function makeReadableSpan(overrides: Record = {}): { + name: string; + kind: number; + spanContext: () => { traceId: string; spanId: string }; + parentSpanId?: string; + startTime: [number, number]; + endTime: [number, number]; + status: { code: number; message?: string }; + attributes: Record; + duration: [number, number]; +} { + const now = Date.now(); + const seconds = Math.floor(now / 1000); + const nanos = (now % 1000) * 1_000_000; + + return { + name: "test.span", + kind: 0, // INTERNAL + spanContext: () => ({ + traceId: "abc123def456", + spanId: "span001", + }), + startTime: [seconds, nanos], + endTime: [seconds, nanos + 10_000_000], // +10ms + status: { code: 1 }, // OK + attributes: {}, + duration: [0, 10_000_000], // 10ms + ...overrides, + }; +} + +describe("observability/tracing/exporters/console-exporter", () => { + describe("convertReadableSpan", () => { + it("should convert a basic span", () => { + const readable = makeReadableSpan(); + const entry = convertReadableSpan(readable); + + assertEquals(entry.name, "test.span"); + assertEquals(entry.kind, "internal"); + assertEquals(entry.status, "ok"); + assertEquals(entry.traceId, "abc123def456"); + assertEquals(entry.spanId, "span001"); + assertEquals(entry.duration >= 9 && entry.duration <= 11, true); + }); + + it("should map span kinds correctly", () => { + assertEquals(convertReadableSpan(makeReadableSpan({ kind: 0 })).kind, "internal"); + assertEquals(convertReadableSpan(makeReadableSpan({ kind: 1 })).kind, "server"); + assertEquals(convertReadableSpan(makeReadableSpan({ kind: 2 })).kind, "client"); + assertEquals(convertReadableSpan(makeReadableSpan({ kind: 3 })).kind, "producer"); + assertEquals(convertReadableSpan(makeReadableSpan({ kind: 4 })).kind, "consumer"); + }); + + it("should map status codes correctly", () => { + assertEquals(convertReadableSpan(makeReadableSpan({ status: { code: 0 } })).status, "unset"); + assertEquals(convertReadableSpan(makeReadableSpan({ status: { code: 1 } })).status, "ok"); + assertEquals(convertReadableSpan(makeReadableSpan({ status: { code: 2 } })).status, "error"); + }); + + it("should include status message for errors", () => { + const entry = convertReadableSpan( + makeReadableSpan({ status: { code: 2, message: "something failed" } }), + ); + assertEquals(entry.status, "error"); + assertEquals(entry.statusMessage, "something failed"); + }); + + it("should flatten attributes", () => { + const entry = convertReadableSpan( + makeReadableSpan({ + attributes: { + "http.method": "GET", + "http.status_code": 200, + "is.ok": true, + nested: { a: 1 }, + }, + }), + ); + + assertEquals(entry.attributes["http.method"], "GET"); + assertEquals(entry.attributes["http.status_code"], 200); + assertEquals(entry.attributes["is.ok"], true); + assertEquals(entry.attributes.nested, "[object Object]"); + }); + + it("should include parentSpanId", () => { + const entry = convertReadableSpan(makeReadableSpan({ parentSpanId: "parent-123" })); + assertEquals(entry.parentSpanId, "parent-123"); + }); + }); + + describe("ConsoleSpanExporter", () => { + it("should export spans to SpanBuffer", () => { + resetSpanBuffer(); + const exporter = new ConsoleSpanExporter(); + const spans = [makeReadableSpan({ name: "test.export" })]; + + let resultCode = -1; + exporter.export(spans, (result) => { + resultCode = result.code; + }); + + assertEquals(resultCode, 0); + + const buffer = getSpanBuffer(); + assertEquals(buffer.count, 1); + assertEquals(buffer.getAll()[0].name, "test.export"); + }); + + it("should export multiple spans", () => { + resetSpanBuffer(); + const exporter = new ConsoleSpanExporter(); + const spans = [ + makeReadableSpan({ name: "span-a" }), + makeReadableSpan({ name: "span-b" }), + makeReadableSpan({ name: "span-c" }), + ]; + + let resultCode = -1; + exporter.export(spans, (result) => { + resultCode = result.code; + }); + + assertEquals(resultCode, 0); + assertEquals(getSpanBuffer().count, 3); + }); + + it("should fail after shutdown", () => { + resetSpanBuffer(); + const exporter = new ConsoleSpanExporter(); + exporter.shutdown(); + + let resultCode = -1; + exporter.export([makeReadableSpan()], (result) => { + resultCode = result.code; + }); + + assertEquals(resultCode, 1); + assertEquals(getSpanBuffer().count, 0); + }); + + it("should handle forceFlush", async () => { + const exporter = new ConsoleSpanExporter(); + await exporter.forceFlush(); + // Should not throw + }); + + it("should skip malformed spans without failing", () => { + resetSpanBuffer(); + const exporter = new ConsoleSpanExporter(); + + const badSpan = { + name: null, + kind: 0, + spanContext: () => { + throw new Error("bad span"); + }, + startTime: [0, 0] as [number, number], + endTime: [0, 0] as [number, number], + status: { code: 0 }, + attributes: {}, + duration: [0, 0] as [number, number], + }; + + let resultCode = -1; + exporter.export([badSpan as never], (result) => { + resultCode = result.code; + }); + + assertEquals(resultCode, 0); + }); + }); +}); diff --git a/src/observability/tracing/exporters/console-exporter.ts b/src/observability/tracing/exporters/console-exporter.ts new file mode 100644 index 0000000000..25ab659855 --- /dev/null +++ b/src/observability/tracing/exporters/console-exporter.ts @@ -0,0 +1,188 @@ +/** + * Console Span Exporter + * + * Implements the OTel SpanExporter interface for dev mode. + * Formats spans as colored terminal output and appends to SpanBuffer. + */ + +import { serverLogger } from "#veryfront/utils"; +import { getSpanBuffer, type SpanEntry, type SpanKind, type SpanStatus } from "../span-buffer.ts"; + +const logger = serverLogger.component("tracing"); + +interface ReadableSpan { + name: string; + kind: number; + spanContext(): { traceId: string; spanId: string }; + parentSpanId?: string; + startTime: [number, number]; // [seconds, nanoseconds] + endTime: [number, number]; + status: { code: number; message?: string }; + attributes: Record; + duration: [number, number]; +} + +interface ExportResult { + code: number; +} + +const SPAN_KIND_MAP: Record = { + 0: "internal", + 1: "server", + 2: "client", + 3: "producer", + 4: "consumer", +}; + +const STATUS_CODE_MAP: Record = { + 0: "unset", + 1: "ok", + 2: "error", +}; + +// ANSI color codes +const COLORS = { + reset: "\x1b[0m", + dim: "\x1b[2m", + bold: "\x1b[1m", + cyan: "\x1b[36m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + gray: "\x1b[90m", + white: "\x1b[37m", + magenta: "\x1b[35m", +}; + +function hrtimeToMs(hrtime: [number, number]): number { + return hrtime[0] * 1000 + hrtime[1] / 1_000_000; +} + +function hrtimeToEpochMs(hrtime: [number, number]): number { + return hrtime[0] * 1000 + hrtime[1] / 1_000_000; +} + +function formatDuration(ms: number): string { + if (ms < 1) return `${(ms * 1000).toFixed(0)}us`; + if (ms < 1000) return `${ms.toFixed(1)}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +function statusColor(status: SpanStatus): string { + if (status === "error") return COLORS.red; + if (status === "ok") return COLORS.green; + return COLORS.yellow; +} + +function kindLabel(kind: SpanKind): string { + if (kind === "server") return `${COLORS.cyan}[srv]${COLORS.reset}`; + if (kind === "client") return `${COLORS.magenta}[cli]${COLORS.reset}`; + return `${COLORS.gray}[int]${COLORS.reset}`; +} + +function flattenAttributes( + attrs: Record, +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(attrs)) { + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + result[key] = value; + } else if (value != null) { + result[key] = String(value); + } + } + return result; +} + +function formatAttributes(attrs: Record): string { + const entries = Object.entries(attrs); + if (entries.length === 0) return ""; + + const parts = entries + .filter(([key]) => !key.startsWith("_")) + .slice(0, 5) + .map(([key, value]) => `${COLORS.gray}${key}=${COLORS.reset}${value}`); + + if (entries.length > 5) { + parts.push(`${COLORS.gray}+${entries.length - 5} more${COLORS.reset}`); + } + + return parts.length > 0 ? ` ${parts.join(" ")}` : ""; +} + +export function convertReadableSpan(span: ReadableSpan): Omit { + const durationMs = hrtimeToMs(span.duration); + const startTimeMs = hrtimeToEpochMs(span.startTime); + const endTimeMs = hrtimeToEpochMs(span.endTime); + const kind = SPAN_KIND_MAP[span.kind] ?? "internal"; + const status = STATUS_CODE_MAP[span.status.code] ?? "unset"; + const attributes = flattenAttributes(span.attributes); + + return { + traceId: span.spanContext().traceId, + spanId: span.spanContext().spanId, + parentSpanId: span.parentSpanId, + name: span.name, + kind, + status, + statusMessage: span.status.message, + startTime: startTimeMs, + endTime: endTimeMs, + duration: durationMs, + attributes, + }; +} + +function formatSpanLine(entry: Omit): string { + const time = new Date(entry.startTime).toISOString().slice(11, 23); + const dur = formatDuration(entry.duration); + const statusStr = statusColor(entry.status); + const kindStr = kindLabel(entry.kind); + const attrs = formatAttributes(entry.attributes); + + return ( + `${COLORS.dim}${time}${COLORS.reset} ` + + `${kindStr} ` + + `${statusStr}${COLORS.bold}${entry.name}${COLORS.reset} ` + + `${COLORS.white}${dur}${COLORS.reset}` + + `${attrs}` + ); +} + +export class ConsoleSpanExporter { + private _shutdown = false; + + export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void { + if (this._shutdown) { + resultCallback({ code: 1 }); + return; + } + + const buffer = getSpanBuffer(); + + for (const span of spans) { + try { + const entry = convertReadableSpan(span); + + // Append to SpanBuffer for dashboard + buffer.append(entry); + + // Log to console + logger.info(formatSpanLine(entry)); + } catch { + // Skip malformed spans + } + } + + resultCallback({ code: 0 }); + } + + shutdown(): Promise { + this._shutdown = true; + return Promise.resolve(); + } + + forceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/src/observability/tracing/index.ts b/src/observability/tracing/index.ts index 3896354348..8a88462aa2 100644 --- a/src/observability/tracing/index.ts +++ b/src/observability/tracing/index.ts @@ -132,3 +132,14 @@ export function withSpanSync( export { tracingManager } from "./manager.ts"; export { TracingManager } from "./manager.ts"; + +export { + getSpanBuffer, + resetSpanBuffer, + SpanBuffer, + type SpanEntry, + type SpanFilter, + type SpanKind, + type SpanStatus, + type SpanSubscriber, +} from "./span-buffer.ts"; diff --git a/src/observability/tracing/manager.ts b/src/observability/tracing/manager.ts index 989ff52095..e87a8b3c87 100644 --- a/src/observability/tracing/manager.ts +++ b/src/observability/tracing/manager.ts @@ -59,17 +59,84 @@ export class TracingManager { const api = (await import("@opentelemetry/api")) as OpenTelemetryAPI; this.state.api = api; - this.state.tracer = api.trace.getTracer(config.serviceName ?? "veryfront", VERSION); - const { W3CTraceContextPropagator } = await import("@opentelemetry/core"); const propagator = new W3CTraceContextPropagator(); this.state.propagator = propagator; api.propagation.setGlobalPropagator(propagator); + if (config.exporter === "console") { + await this.initializeConsoleTracer(api, config); + } else if (config.exporter === "otlp" && config.endpoint) { + await this.initializeOTLPTracer(api, config); + } else { + // Fallback: tracer without processor (spans are no-ops) + this.state.tracer = api.trace.getTracer(config.serviceName ?? "veryfront", VERSION); + } + this.spanOps = this.state.tracer ? new SpanOperations(api, this.state.tracer) : null; this.contextProp = new ContextPropagation(api, propagator); } + private async initializeConsoleTracer( + api: OpenTelemetryAPI, + config: TracingConfig, + ): Promise { + const { BasicTracerProvider, SimpleSpanProcessor } = await import( + "@opentelemetry/sdk-trace-base" + ); + const { Resource } = await import("@opentelemetry/resources"); + const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = await import( + "@opentelemetry/semantic-conventions" + ); + const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks"); + const { ConsoleSpanExporter } = await import("./exporters/console-exporter.ts"); + + const resource = new Resource({ + [ATTR_SERVICE_NAME]: config.serviceName ?? "veryfront", + [ATTR_SERVICE_VERSION]: VERSION, + }); + + const provider = new BasicTracerProvider({ resource }); + provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter())); + + const contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + provider.register({ contextManager }); + + this.state.tracer = api.trace.getTracer(config.serviceName ?? "veryfront", VERSION); + } + + private async initializeOTLPTracer(api: OpenTelemetryAPI, config: TracingConfig): Promise { + const { BasicTracerProvider, BatchSpanProcessor } = await import( + "@opentelemetry/sdk-trace-base" + ); + const { OTLPTraceExporter } = await import("@opentelemetry/exporter-trace-otlp-http"); + const { Resource } = await import("@opentelemetry/resources"); + const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = await import( + "@opentelemetry/semantic-conventions" + ); + const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks"); + + const resource = new Resource({ + [ATTR_SERVICE_NAME]: config.serviceName ?? "veryfront", + [ATTR_SERVICE_VERSION]: VERSION, + }); + + const endpointBase = (config.endpoint ?? "").replace(/\/$/, ""); + const exporter = new OTLPTraceExporter({ + url: `${endpointBase}/v1/traces`, + }); + + const provider = new BasicTracerProvider({ resource }); + provider.addSpanProcessor(new BatchSpanProcessor(exporter)); + + const contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + provider.register({ contextManager }); + + this.state.tracer = api.trace.getTracer(config.serviceName ?? "veryfront", VERSION); + } + isEnabled(): boolean { return this.state.initialized && this.state.tracer !== null; } diff --git a/src/observability/tracing/span-buffer.test.ts b/src/observability/tracing/span-buffer.test.ts new file mode 100644 index 0000000000..b12b641c64 --- /dev/null +++ b/src/observability/tracing/span-buffer.test.ts @@ -0,0 +1,257 @@ +import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { SpanBuffer, type SpanEntry } from "./span-buffer.ts"; + +function makeSpan(overrides: Partial> = {}): Omit { + return { + traceId: "trace-1", + spanId: `span-${Math.random().toString(36).slice(2, 8)}`, + name: "test.span", + kind: "internal", + status: "ok", + startTime: Date.now(), + endTime: Date.now() + 10, + duration: 10, + attributes: {}, + ...overrides, + }; +} + +describe("observability/tracing/span-buffer", () => { + describe("SpanBuffer", () => { + it("should append span entries", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan()); + assertEquals(buf.count, 1); + }); + + it("should assign unique IDs", () => { + const buf = new SpanBuffer(); + const a = buf.append(makeSpan()); + const b = buf.append(makeSpan()); + assertEquals(a.id !== b.id, true); + }); + + it("should enforce maxSize with FIFO eviction", () => { + const buf = new SpanBuffer({ maxSize: 3 }); + buf.append(makeSpan({ name: "span-1" })); + buf.append(makeSpan({ name: "span-2" })); + buf.append(makeSpan({ name: "span-3" })); + buf.append(makeSpan({ name: "span-4" })); + + assertEquals(buf.count, 3); + + const first = buf.getAll()[0]; + assertExists(first); + assertEquals(first.name, "span-2"); + }); + + it("should query without filter", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan()); + buf.append(makeSpan()); + + const results = buf.query(); + assertEquals(results.length, 2); + }); + + it("should query by traceId", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ traceId: "trace-a" })); + buf.append(makeSpan({ traceId: "trace-b" })); + buf.append(makeSpan({ traceId: "trace-a" })); + + const results = buf.query({ traceId: "trace-a" }); + assertEquals(results.length, 2); + }); + + it("should query by name string", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ name: "http.request" })); + buf.append(makeSpan({ name: "render.page" })); + buf.append(makeSpan({ name: "http.fetch" })); + + const results = buf.query({ name: "http" }); + assertEquals(results.length, 2); + }); + + it("should query by name regex", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ name: "http.request" })); + buf.append(makeSpan({ name: "render.page" })); + + const results = buf.query({ name: /^render/ }); + assertEquals(results.length, 1); + }); + + it("should query by status", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ status: "ok" })); + buf.append(makeSpan({ status: "error" })); + buf.append(makeSpan({ status: "ok" })); + + const results = buf.query({ status: "error" }); + assertEquals(results.length, 1); + }); + + it("should query by multiple statuses", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ status: "ok" })); + buf.append(makeSpan({ status: "error" })); + buf.append(makeSpan({ status: "unset" })); + + const results = buf.query({ status: ["ok", "error"] }); + assertEquals(results.length, 2); + }); + + it("should query by kind", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ kind: "server" })); + buf.append(makeSpan({ kind: "client" })); + buf.append(makeSpan({ kind: "internal" })); + + const results = buf.query({ kind: "server" }); + assertEquals(results.length, 1); + }); + + it("should query by minDuration", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ duration: 5 })); + buf.append(makeSpan({ duration: 50 })); + buf.append(makeSpan({ duration: 100 })); + + const results = buf.query({ minDuration: 50 }); + assertEquals(results.length, 2); + }); + + it("should query by maxDuration", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ duration: 5 })); + buf.append(makeSpan({ duration: 50 })); + buf.append(makeSpan({ duration: 100 })); + + const results = buf.query({ maxDuration: 50 }); + assertEquals(results.length, 2); + }); + + it("should query by since", () => { + const now = Date.now(); + const buf = new SpanBuffer(); + buf.append(makeSpan({ startTime: now - 1000 })); + buf.append(makeSpan({ startTime: now })); + + const results = buf.query({ since: now }); + assertEquals(results.length, 1); + }); + + it("should query with limit", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ name: "a" })); + buf.append(makeSpan({ name: "b" })); + buf.append(makeSpan({ name: "c" })); + + const results = buf.query({ limit: 2 }); + assertEquals(results.length, 2); + assertEquals(results[0].name, "b"); + }); + + it("should get trace by traceId sorted by startTime", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ traceId: "t1", name: "child", startTime: 200 })); + buf.append(makeSpan({ traceId: "t1", name: "root", startTime: 100 })); + buf.append(makeSpan({ traceId: "t2", name: "other", startTime: 150 })); + + const trace = buf.getTrace("t1"); + assertEquals(trace.length, 2); + assertEquals(trace[0].name, "root"); + assertEquals(trace[1].name, "child"); + }); + + it("should get grouped traces", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ traceId: "t1", name: "root", startTime: 100, endTime: 200 })); + buf.append( + makeSpan({ traceId: "t1", name: "child", startTime: 110, endTime: 180, parentSpanId: "p" }), + ); + buf.append(makeSpan({ traceId: "t2", name: "other-root", startTime: 300, endTime: 400 })); + + const traces = buf.getTraces(); + assertEquals(traces.length, 2); + // Most recent first + assertEquals(traces[0].traceId, "t2"); + assertEquals(traces[1].traceId, "t1"); + assertEquals(traces[1].spans.length, 2); + }); + + it("should limit grouped traces", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ traceId: "t1", startTime: 100, endTime: 200 })); + buf.append(makeSpan({ traceId: "t2", startTime: 200, endTime: 300 })); + buf.append(makeSpan({ traceId: "t3", startTime: 300, endTime: 400 })); + + const traces = buf.getTraces({ limit: 2 }); + assertEquals(traces.length, 2); + }); + + it("should count by status", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ status: "ok" })); + buf.append(makeSpan({ status: "ok" })); + buf.append(makeSpan({ status: "error" })); + + const counts = buf.countByStatus(); + assertEquals(counts.ok, 2); + assertEquals(counts.error, 1); + assertEquals(counts.unset, 0); + }); + + it("should clear entries", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan()); + buf.clear(); + assertEquals(buf.count, 0); + }); + + it("should notify subscribers", () => { + const buf = new SpanBuffer(); + const received: string[] = []; + const unsub = buf.subscribe((entry) => received.push(entry.name)); + + buf.append(makeSpan({ name: "test" })); + assertEquals(received, ["test"]); + + unsub(); + buf.append(makeSpan({ name: "after-unsub" })); + assertEquals(received.length, 1); + }); + + it("should ignore subscriber errors", () => { + const buf = new SpanBuffer(); + buf.subscribe(() => { + throw new Error("subscriber error"); + }); + + const entry = buf.append(makeSpan()); + assertExists(entry.id); + }); + + it("should format entries", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan({ name: "http.request", status: "ok", duration: 42.5 })); + + const formatted = buf.format(); + assertEquals(formatted.includes("OK"), true); + assertEquals(formatted.includes("http.request"), true); + assertEquals(formatted.includes("42.5ms"), true); + }); + + it("should serialize to JSON", () => { + const buf = new SpanBuffer(); + buf.append(makeSpan()); + buf.append(makeSpan()); + + const json = buf.toJSON(); + assertEquals(json.length, 2); + }); + }); +}); diff --git a/src/observability/tracing/span-buffer.ts b/src/observability/tracing/span-buffer.ts new file mode 100644 index 0000000000..dc170bd865 --- /dev/null +++ b/src/observability/tracing/span-buffer.ts @@ -0,0 +1,215 @@ +export type SpanKind = "server" | "client" | "internal" | "producer" | "consumer"; +export type SpanStatus = "ok" | "error" | "unset"; + +export interface SpanEntry { + id: string; + traceId: string; + spanId: string; + parentSpanId?: string; + name: string; + kind: SpanKind; + status: SpanStatus; + statusMessage?: string; + startTime: number; + endTime: number; + duration: number; + attributes: Record; +} + +export interface SpanFilter { + traceId?: string; + name?: string | RegExp; + status?: SpanStatus | SpanStatus[]; + kind?: SpanKind | SpanKind[]; + minDuration?: number; + maxDuration?: number; + since?: number; + limit?: number; +} + +export type SpanSubscriber = (entry: SpanEntry) => void; + +export class SpanBuffer { + private entries: SpanEntry[] = []; + private subscribers = new Set(); + private idCounter = 0; + private maxSize: number; + + constructor(options: { maxSize?: number } = {}) { + this.maxSize = options.maxSize ?? 1000; + } + + private generateId(): string { + return `span_${Date.now()}_${++this.idCounter}`; + } + + append(entry: Omit): SpanEntry { + const fullEntry: SpanEntry = { + ...entry, + id: this.generateId(), + }; + + this.entries.push(fullEntry); + + while (this.entries.length > this.maxSize) { + this.entries.shift(); + } + + for (const subscriber of this.subscribers) { + try { + subscriber(fullEntry); + } catch { + // Ignore subscriber errors + } + } + + return fullEntry; + } + + query(filter?: SpanFilter): SpanEntry[] { + if (!filter) return [...this.entries]; + + let results = [...this.entries]; + + if (filter.traceId) { + results = results.filter((e) => e.traceId === filter.traceId); + } + + if (filter.name) { + const { name } = filter; + if (typeof name === "string") { + const lower = name.toLowerCase(); + results = results.filter((e) => e.name.toLowerCase().includes(lower)); + } else { + results = results.filter((e) => name.test(e.name)); + } + } + + if (filter.status) { + const statuses = Array.isArray(filter.status) ? filter.status : [filter.status]; + results = results.filter((e) => statuses.includes(e.status)); + } + + if (filter.kind) { + const kinds = Array.isArray(filter.kind) ? filter.kind : [filter.kind]; + results = results.filter((e) => kinds.includes(e.kind)); + } + + if (filter.minDuration != null) { + results = results.filter((e) => e.duration >= filter.minDuration!); + } + + if (filter.maxDuration != null) { + results = results.filter((e) => e.duration <= filter.maxDuration!); + } + + if (filter.since != null) { + results = results.filter((e) => e.startTime >= filter.since!); + } + + if (filter.limit != null) { + results = results.slice(-filter.limit); + } + + return results; + } + + getTrace(traceId: string): SpanEntry[] { + return this.entries + .filter((e) => e.traceId === traceId) + .sort((a, b) => a.startTime - b.startTime); + } + + getTraces(options?: { limit?: number; since?: number }): Array<{ + traceId: string; + rootSpan: SpanEntry; + spans: SpanEntry[]; + duration: number; + }> { + const traceMap = new Map(); + + for (const entry of this.entries) { + if (options?.since != null && entry.startTime < options.since) continue; + + const spans = traceMap.get(entry.traceId); + if (spans) { + spans.push(entry); + } else { + traceMap.set(entry.traceId, [entry]); + } + } + + const traces = Array.from(traceMap.entries()) + .map(([traceId, spans]) => { + spans.sort((a, b) => a.startTime - b.startTime); + // spans always has at least one entry (built from map insertion) + const rootSpan = (spans.find((s) => !s.parentSpanId) ?? spans[0])!; + const minStart = Math.min(...spans.map((s) => s.startTime)); + const maxEnd = Math.max(...spans.map((s) => s.endTime)); + return { traceId, rootSpan, spans, duration: maxEnd - minStart }; + }) + .sort((a, b) => b.rootSpan.startTime - a.rootSpan.startTime); + + if (options?.limit != null) { + return traces.slice(0, options.limit); + } + + return traces; + } + + getAll(): SpanEntry[] { + return [...this.entries]; + } + + clear(): void { + this.entries = []; + } + + get count(): number { + return this.entries.length; + } + + countByStatus(): Record { + const counts: Record = { ok: 0, error: 0, unset: 0 }; + + for (const entry of this.entries) { + counts[entry.status]++; + } + + return counts; + } + + subscribe(callback: SpanSubscriber): () => void { + this.subscribers.add(callback); + return () => this.subscribers.delete(callback); + } + + toJSON(): SpanEntry[] { + return this.getAll(); + } + + format(entries?: SpanEntry[]): string { + const spans = entries ?? this.entries; + + return spans + .map((e) => { + const time = new Date(e.startTime).toISOString().slice(11, 23); + const status = e.status.toUpperCase().padEnd(5); + const dur = `${e.duration.toFixed(1)}ms`.padStart(9); + return `${time} ${status} ${dur} ${e.name}`; + }) + .join("\n"); + } +} + +let globalBuffer: SpanBuffer | null = null; + +export function getSpanBuffer(): SpanBuffer { + globalBuffer ??= new SpanBuffer(); + return globalBuffer; +} + +export function resetSpanBuffer(): void { + globalBuffer?.clear(); + globalBuffer = null; +} diff --git a/src/server/dev-server/server.ts b/src/server/dev-server/server.ts index 7a004a9003..81905e491e 100644 --- a/src/server/dev-server/server.ts +++ b/src/server/dev-server/server.ts @@ -141,6 +141,17 @@ export class DevServer { }); } + // Zero-config: enable console tracing in dev mode + import("#veryfront/observability").then(({ initAutoInstrumentation }) => + initAutoInstrumentation({ + tracing: { enabled: true, exporter: "console", serviceName: "veryfront-dev" }, + instrumentHttp: true, + instrumentFetch: true, + }) + ).catch((err) => { + devServerLog.debug("Tracing init failed (non-fatal)", err); + }); + // Auto-discover AI primitives (tools, agents, workflows, prompts, resources) await this.runAIDiscovery(); diff --git a/src/server/dev-ui/dashboard/components/RuntimeTab.tsx b/src/server/dev-ui/dashboard/components/RuntimeTab.tsx index 890af9902d..dc14ae07c8 100644 --- a/src/server/dev-ui/dashboard/components/RuntimeTab.tsx +++ b/src/server/dev-ui/dashboard/components/RuntimeTab.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; import { Card } from "./Card.tsx"; import { ErrorState, LoadingState, PageLayout } from "./shared.tsx"; +import { TracesSection } from "./TracesSection.tsx"; -type SubTab = "metrics" | "memory"; +type SubTab = "metrics" | "memory" | "traces"; interface HeapStats { usedHeapSizeMB: number; @@ -95,6 +96,11 @@ export function RuntimeTab(): React.JSX.Element { onClick={() => setSubTab("memory")} label={`Memory (${memory?.caches.length ?? 0} caches)`} /> + setSubTab("traces")} + label="Traces" + />
Auto-refresh: 15s @@ -111,6 +117,7 @@ export function RuntimeTab(): React.JSX.Element { {subTab === "metrics" && } {subTab === "memory" && memory && } + {subTab === "traces" && } ); } diff --git a/src/server/dev-ui/dashboard/components/TracesSection.tsx b/src/server/dev-ui/dashboard/components/TracesSection.tsx new file mode 100644 index 0000000000..46a6dd1659 --- /dev/null +++ b/src/server/dev-ui/dashboard/components/TracesSection.tsx @@ -0,0 +1,400 @@ +import { useEffect, useState } from "react"; +import { Card } from "./Card.tsx"; +import { ErrorState, LoadingState } from "./shared.tsx"; + +interface SpanEntry { + id: string; + traceId: string; + spanId: string; + parentSpanId?: string; + name: string; + kind: "server" | "client" | "internal"; + status: "ok" | "error" | "unset"; + statusMessage?: string; + startTime: number; + endTime: number; + duration: number; + attributes: Record; +} + +interface TraceGroup { + traceId: string; + rootSpan: SpanEntry; + spans: SpanEntry[]; + duration: number; +} + +interface TracesResponse { + traces: TraceGroup[]; + spans: number; + countByStatus: Record; + total: number; +} + +function formatDuration(ms: number): string { + if (ms < 1) return `${(ms * 1000).toFixed(0)}us`; + if (ms < 1000) return `${ms.toFixed(1)}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +function statusBadge(status: string): React.JSX.Element { + const colors: Record = { + ok: "bg-green-100 text-green-700", + error: "bg-red-100 text-red-700", + unset: "bg-gray-100 text-gray-600", + }; + + return ( + + {status} + + ); +} + +function kindBadge(kind: string): React.JSX.Element { + const colors: Record = { + server: "text-cyan-600", + client: "text-purple-600", + internal: "text-gray-500", + }; + + return ( + + {kind.slice(0, 3)} + + ); +} + +function WaterfallBar({ + span, + traceStart, + traceDuration, +}: { + span: SpanEntry; + traceStart: number; + traceDuration: number; +}): React.JSX.Element { + const offset = traceDuration > 0 ? ((span.startTime - traceStart) / traceDuration) * 100 : 0; + const width = traceDuration > 0 ? Math.max((span.duration / traceDuration) * 100, 0.5) : 100; + + const barColor = span.status === "error" + ? "bg-red-400" + : span.kind === "server" + ? "bg-cyan-400" + : span.kind === "client" + ? "bg-purple-400" + : "bg-sky-300"; + + return ( +
+
+
+ ); +} + +function buildSpanTree( + spans: SpanEntry[], +): Array<{ span: SpanEntry; depth: number }> { + const childMap = new Map(); + + for (const span of spans) { + const parentKey = span.parentSpanId ?? undefined; + const children = childMap.get(parentKey); + if (children) { + children.push(span); + } else { + childMap.set(parentKey, [span]); + } + } + + const result: Array<{ span: SpanEntry; depth: number }> = []; + + function walk(parentId: string | undefined, depth: number): void { + const children = childMap.get(parentId) ?? []; + children.sort((a, b) => a.startTime - b.startTime); + for (const child of children) { + result.push({ span: child, depth }); + walk(child.spanId, depth + 1); + } + } + + // Find root spans (no parent, or parent not in this trace) + const spanIds = new Set(spans.map((s) => s.spanId)); + const roots = spans.filter((s) => !s.parentSpanId || !spanIds.has(s.parentSpanId)); + roots.sort((a, b) => a.startTime - b.startTime); + + for (const root of roots) { + result.push({ span: root, depth: 0 }); + walk(root.spanId, 1); + } + + return result; +} + +function TraceDetail({ + trace, + onClose, +}: { + trace: TraceGroup; + onClose: () => void; +}): React.JSX.Element { + const traceStart = Math.min(...trace.spans.map((s) => s.startTime)); + const tree = buildSpanTree(trace.spans); + const [selectedSpan, setSelectedSpan] = useState(null); + + return ( + +
+
+ + {trace.traceId.slice(0, 8)}... + + {trace.rootSpan.name} + {formatDuration(trace.duration)} + {trace.spans.length} spans +
+ +
+ +
+ {tree.map(({ span, depth }) => ( + + ))} +
+ + {selectedSpan && ( +
+
Span Detail
+
+ Name + {selectedSpan.name} + Span ID + {selectedSpan.spanId} + Duration + {formatDuration(selectedSpan.duration)} + Kind + {selectedSpan.kind} + Status + + {selectedSpan.status} + {selectedSpan.statusMessage ? `: ${selectedSpan.statusMessage}` : ""} + +
+ {Object.keys(selectedSpan.attributes).length > 0 && ( + <> +
Attributes
+ + + {Object.entries(selectedSpan.attributes).map(([key, value]) => ( + + + + + ))} + +
{key}{String(value)}
+ + )} +
+ )} +
+ ); +} + +export function TracesSection(): React.JSX.Element { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedTrace, setExpandedTrace] = useState(null); + const [nameFilter, setNameFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [minDuration, setMinDuration] = useState(""); + + function loadTraces(): void { + const params = new URLSearchParams(); + if (nameFilter) params.set("name", nameFilter); + if (statusFilter) params.set("status", statusFilter); + if (minDuration) params.set("minDuration", minDuration); + params.set("limit", "50"); + + fetch(`/_dev/api/traces?${params}`) + .then((r) => r.json()) + .then((d) => { + setData(d as TracesResponse); + setError(null); + }) + .catch((e) => setError((e as Error).message)) + .finally(() => setLoading(false)); + } + + useEffect(() => { + loadTraces(); + const interval = setInterval(loadTraces, 5000); + return () => clearInterval(interval); + }, [nameFilter, statusFilter, minDuration]); + + if (!data && loading) { + return ( + + + + ); + } + + if (!data && error) { + return ( + + + + ); + } + + const traces = data?.traces ?? []; + + return ( +
+
+ setNameFilter(e.target.value)} + placeholder="Filter by name..." + className="px-2 py-1.5 text-sm border border-gray-200 rounded w-48" + /> + + setMinDuration(e.target.value)} + placeholder="Min ms..." + className="px-2 py-1.5 text-sm border border-gray-200 rounded w-24" + /> +
+ + {data?.total ?? 0} spans, {traces.length} traces + + +
+ + {traces.length === 0 + ? ( + +
+ No traces recorded yet. Make some requests to see traces appear. +
+
+ ) + : ( + <> + {expandedTrace + ? ( + (() => { + const trace = traces.find((t) => t.traceId === expandedTrace); + return trace + ? setExpandedTrace(null)} /> + : null; + })() + ) + : null} + + + + + + + + + + + + + + {traces.map((trace) => ( + + setExpandedTrace(expandedTrace === trace.traceId ? null : trace.traceId)} + className={`border-b last:border-0 cursor-pointer hover:bg-gray-50 ${ + expandedTrace === trace.traceId ? "bg-sky-50" : "" + }`} + > + + + + + + + ))} + +
+ Trace + + Root Span + + Duration + + Spans + + Status +
+ {trace.traceId.slice(0, 8)} + + {trace.rootSpan.name} + + {formatDuration(trace.duration)} + {trace.spans.length} + {statusBadge(trace.rootSpan.status)} +
+
+ + )} +
+ ); +} diff --git a/src/server/handlers/dev/dashboard/api.ts b/src/server/handlers/dev/dashboard/api.ts index dcccc91b44..bf8e4e1fd3 100644 --- a/src/server/handlers/dev/dashboard/api.ts +++ b/src/server/handlers/dev/dashboard/api.ts @@ -22,6 +22,8 @@ import { isRSCEnabled } from "#veryfront/utils/feature-flags.ts"; import { getEnvironmentConfig } from "#veryfront/config/environment-config.ts"; import { getErrorCollector } from "#veryfront/observability/error-collector.ts"; import { getLogBuffer } from "#veryfront/observability/log-buffer.ts"; +import { getSpanBuffer } from "#veryfront/observability/tracing/span-buffer.ts"; +import type { SpanStatus } from "#veryfront/observability/tracing/span-buffer.ts"; import { ReloadNotifier } from "../../../reload-notifier.ts"; import type { HandlerContext } from "../../types.ts"; @@ -98,6 +100,8 @@ export function handleDashboardAPI( return handleLiveErrors(req); case "/_dev/api/live-logs": return handleLiveLogs(req); + case "/_dev/api/traces": + return handleGetTraces(req); default: return null; } @@ -594,6 +598,49 @@ function handleLiveLogs(req: Request): Response { }); } +function handleGetTraces(req: Request): Response { + const url = new URL(req.url); + const traceId = url.searchParams.get("traceId") ?? undefined; + const name = url.searchParams.get("name") ?? undefined; + const status = url.searchParams.get("status") ?? undefined; + const minDuration = url.searchParams.get("minDuration"); + const since = url.searchParams.get("since"); + const limit = url.searchParams.get("limit"); + + const buffer = getSpanBuffer(); + + if (traceId) { + const spans = buffer.getTrace(traceId); + return jsonResponse({ + traceId, + spans, + count: spans.length, + timestamp: new Date().toISOString(), + }); + } + + const filtered = buffer.query({ + name, + status: status as SpanStatus | undefined, + minDuration: minDuration ? parseFloat(minDuration) : undefined, + since: since ? parseInt(since, 10) : undefined, + limit: limit ? parseInt(limit, 10) : undefined, + }); + + const traces = buffer.getTraces({ + limit: limit ? parseInt(limit, 10) : 50, + since: since ? parseInt(since, 10) : undefined, + }); + + return jsonResponse({ + traces, + spans: filtered.length, + countByStatus: buffer.countByStatus(), + total: buffer.count, + timestamp: new Date().toISOString(), + }); +} + async function handleHmrTrigger(req: Request): Promise { try { const body = (await req.json().catch(() => ({}))) as { path?: string };