/** * OpenTelemetry integration for the Bun process. * * Controlled by AGOR_OTLP_ENDPOINT env var: * - Set (e.g. "http://localhost:4318") -> OTLP/HTTP trace export + console * - Absent -> console-only (no network calls) * * Provides structured span creation for agent sessions, PTY operations, and * RPC calls. Frontend events are forwarded via the telemetry.log RPC. */ // ── Types ────────────────────────────────────────────────────────────────── export type LogLevel = "info" | "warn" | "error"; export interface SpanAttributes { [key: string]: string | number | boolean; } interface ActiveSpan { name: string; attributes: SpanAttributes; startTime: number; } // ── Telemetry Manager ────────────────────────────────────────────────────── export class TelemetryManager { private enabled = false; private endpoint = ""; private activeSpans = new Map(); private spanCounter = 0; private serviceName = "agent-orchestrator-electrobun"; private serviceVersion = "3.0.0-dev"; /** Initialize telemetry. Call once at startup. */ init(): void { const endpoint = process.env.AGOR_OTLP_ENDPOINT ?? ""; const isTest = process.env.AGOR_TEST === "1"; if (endpoint && !isTest) { this.enabled = true; this.endpoint = endpoint.endsWith("/") ? endpoint + "v1/traces" : endpoint + "/v1/traces"; console.log(`[telemetry] OTLP export enabled -> ${this.endpoint}`); } else { console.log("[telemetry] Console-only (AGOR_OTLP_ENDPOINT not set)"); } } /** Start a named span. Returns a spanId to pass to endSpan(). */ span(name: string, attributes: SpanAttributes = {}): string { const spanId = `span_${++this.spanCounter}_${Date.now()}`; this.activeSpans.set(spanId, { name, attributes, startTime: Date.now(), }); this.consoleLog("info", `[span:start] ${name}`, attributes); return spanId; } /** End a span and optionally export it via OTLP. */ endSpan(spanId: string, extraAttributes: SpanAttributes = {}): void { const active = this.activeSpans.get(spanId); if (!active) return; this.activeSpans.delete(spanId); const durationMs = Date.now() - active.startTime; const allAttributes = { ...active.attributes, ...extraAttributes, durationMs }; this.consoleLog("info", `[span:end] ${active.name} (${durationMs}ms)`, allAttributes); if (this.enabled) { this.exportSpan(active.name, active.startTime, durationMs, allAttributes); } } /** Log a structured message. Used for frontend-forwarded events. */ log(level: LogLevel, message: string, attributes: SpanAttributes = {}): void { this.consoleLog(level, message, attributes); if (this.enabled) { this.exportLog(level, message, attributes); } } /** Shutdown — flush any pending exports. */ shutdown(): void { this.activeSpans.clear(); if (this.enabled) { console.log("[telemetry] Shutdown"); } } // ── Internal ───────────────────────────────────────────────────────────── private consoleLog(level: LogLevel, message: string, attrs: SpanAttributes): void { const attrStr = Object.keys(attrs).length > 0 ? ` ${JSON.stringify(attrs)}` : ""; switch (level) { case "error": console.error(`[tel] ${message}${attrStr}`); break; case "warn": console.warn(`[tel] ${message}${attrStr}`); break; default: console.log(`[tel] ${message}${attrStr}`); break; } } private async exportSpan( name: string, startTimeMs: number, durationMs: number, attributes: SpanAttributes, ): Promise { const traceId = this.randomHex(32); const spanId = this.randomHex(16); const startNs = BigInt(startTimeMs) * 1_000_000n; const endNs = BigInt(startTimeMs + durationMs) * 1_000_000n; const otlpPayload = { resourceSpans: [{ resource: { attributes: [ { key: "service.name", value: { stringValue: this.serviceName } }, { key: "service.version", value: { stringValue: this.serviceVersion } }, ], }, scopeSpans: [{ scope: { name: this.serviceName }, spans: [{ traceId, spanId, name, kind: 1, // INTERNAL startTimeUnixNano: startNs.toString(), endTimeUnixNano: endNs.toString(), attributes: Object.entries(attributes).map(([key, value]) => ({ key, value: typeof value === "number" ? { intValue: value } : typeof value === "boolean" ? { boolValue: value } : { stringValue: String(value) }, })), status: { code: 1 }, // OK }], }], }], }; try { await fetch(this.endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(otlpPayload), signal: AbortSignal.timeout(5_000), }); } catch (err) { console.warn("[telemetry] OTLP export failed:", err instanceof Error ? err.message : err); } } private async exportLog( level: LogLevel, message: string, attributes: SpanAttributes, ): Promise { // Wrap log as a zero-duration span for Tempo compatibility await this.exportSpan( `log.${level}`, Date.now(), 0, { ...attributes, "log.message": message, "log.level": level }, ); } private randomHex(length: number): string { const bytes = new Uint8Array(length / 2); crypto.getRandomValues(bytes); return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } } // ── Singleton ────────────────────────────────────────────────────────────── export const telemetry = new TelemetryManager(); /** Initialize telemetry. Call once at app startup. */ export function initTelemetry(): void { telemetry.init(); }