Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/observability/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ export {
endSpan,
extractContext,
getActiveContext,
getSpanBuffer,
initTracing,
injectContext,
isTracingEnabled,
resetSpanBuffer,
setSpanAttributes,
shutdownTracing,
SpanBuffer,
type SpanEntry,
type SpanFilter,
SpanNames,
type SpanOptions,
startSpan,
Expand Down
178 changes: 178 additions & 0 deletions src/observability/tracing/exporters/console-exporter.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}): {
name: string;
kind: number;
spanContext: () => { traceId: string; spanId: string };
parentSpanId?: string;
startTime: [number, number];
endTime: [number, number];
status: { code: number; message?: string };
attributes: Record<string, unknown>;
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);
});
});
});
188 changes: 188 additions & 0 deletions src/observability/tracing/exporters/console-exporter.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
duration: [number, number];
}

interface ExportResult {
code: number;
}

const SPAN_KIND_MAP: Record<number, SpanKind> = {
0: "internal",
1: "server",
2: "client",
3: "producer",
4: "consumer",
};

const STATUS_CODE_MAP: Record<number, SpanStatus> = {
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;

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hrtimeToEpochMs has the same implementation as hrtimeToMs. Consider removing one helper (or delegating one to the other) to avoid duplication and drift.

Suggested change
return hrtime[0] * 1000 + hrtime[1] / 1_000_000;
return hrtimeToMs(hrtime);

Copilot uses AI. Check for mistakes.
}

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<string, unknown>,
): Record<string, string | number | boolean> {
const result: Record<string, string | number | boolean> = {};
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, string | number | boolean>): 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<SpanEntry, "id"> {
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,
Comment on lines +120 to +123

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

convertReadableSpan calls span.spanContext() twice; consider calling it once and reusing the returned { traceId, spanId } to avoid redundant work and reduce the chance of inconsistencies if spanContext() ever becomes non-trivial.

Suggested change
return {
traceId: span.spanContext().traceId,
spanId: span.spanContext().spanId,
const { traceId, spanId } = span.spanContext();
return {
traceId,
spanId,

Copilot uses AI. Check for mistakes.
parentSpanId: span.parentSpanId,
name: span.name,
kind,
status,
statusMessage: span.status.message,
startTime: startTimeMs,
endTime: endTimeMs,
duration: durationMs,
attributes,
};
}

function formatSpanLine(entry: Omit<SpanEntry, "id">): 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<void> {
this._shutdown = true;
return Promise.resolve();
}

forceFlush(): Promise<void> {
return Promise.resolve();
}
}
11 changes: 11 additions & 0 deletions src/observability/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,14 @@ export function withSpanSync<T>(

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";
Loading