/** * BSB (Better-Service-Base) is an event-bus based microservice framework. * Copyright (C) 2016 - 2025 BetterCorp (PTY) Ltd * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Alternatively, you may obtain a commercial license for this program. * The commercial license allows you to use the Program in a closed-source manner, * including the right to create derivative works that are not subject to the terms * of the AGPL. * * To obtain a commercial license, please contact the copyright holders at * https://www.bettercorp.dev. The terms and conditions of the commercial license * will be provided upon request. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import { BSBObservable, BSBObservableConstructor, createConfigSchema, LogFormatter, BSBError } from "@bsb/base"; import { DTrace, LogMeta } from "@bsb/base"; import * as av from "anyvali"; import { AxiomWithoutBatching } from "@axiomhq/js"; import * as api from "@opentelemetry/api"; import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources"; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import type { IdGenerator } from "@opentelemetry/sdk-trace-base"; const ConfigSchema = av.object({ serviceName: av.string().default("bsb-service").describe("Service name reported to Axiom and OpenTelemetry"), serviceVersion: av.optional(av.string()).describe("Optional service version reported with telemetry"), axiom: av.object({ token: av.string().describe("Axiom API token used for ingesting telemetry", { sensitive: true, writeonly: true }), dataset: av.string().default("bsb-logs").describe("Axiom dataset used for logs and metrics"), orgId: av.optional(av.string()).describe("Optional Axiom organization ID"), url: av.optional(av.string().format("url")).describe("Optional custom Axiom API base URL"), }).describe("Axiom connection settings"), enabled: av.object({ logs: av.bool().default(true).describe("Whether log export to Axiom is enabled"), metrics: av.bool().default(true).describe("Whether metric export to Axiom is enabled"), traces: av.bool().default(true).describe("Whether trace export to Axiom is enabled"), }).describe("Telemetry signal enablement"), export: av.object({ flushIntervalMs: av.int32().min(100).default(5000).describe("Interval in milliseconds between batched log flushes"), maxBatchSize: av.int32().min(1).default(1000).describe("Maximum number of log entries sent in one batch"), }).describe("Axiom export batching settings"), resourceAttributes: av.record(av.string()).default({}).describe("Additional OpenTelemetry resource attributes attached to exported telemetry"), }).describe("Axiom observable plugin configuration"); export const Config = createConfigSchema( { name: 'observable-axiom', description: 'Axiom.co observability integration for logs, metrics, and traces', image: './axiom-co-logo.png', tags: ['axiom', 'observability', 'logs', 'metrics', 'traces', 'analytics'], }, ConfigSchema ); /** * Custom ID generator that uses BSB's trace and span IDs */ class BSBIdGenerator implements IdGenerator { private currentTrace: DTrace | null = null; setCurrentTrace(trace: DTrace): void { this.currentTrace = trace; } clearCurrentTrace(): void { this.currentTrace = null; } generateTraceId(): string { if (this.currentTrace) { // BSB now generates OpenTelemetry-compliant IDs (32 hex chars, no hyphens) return this.currentTrace.t; } // Fallback: generate random ID (should not happen in normal flow) return this.randomHex(32); } generateSpanId(): string { if (this.currentTrace) { // BSB now generates OpenTelemetry-compliant IDs (16 hex chars, no hyphens) return this.currentTrace.s; } // Fallback: generate random ID (should not happen in normal flow) return this.randomHex(16); } private randomHex(length: number): string { const bytes = new Uint8Array(length / 2); crypto.getRandomValues(bytes); return Array.from(bytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); } } /** * Axiom observable plugin for unified observability * * Exports logs and events to Axiom using official SDK * Exports traces via OTLP (Axiom supports OpenTelemetry) * Exports metrics as structured events */ export class Plugin extends BSBObservable> { static Config = Config; private logFormatter = new LogFormatter(); private axiom: AxiomWithoutBatching | null = null; private tracerProvider: NodeTracerProvider | null = null; private tracer: api.Tracer | null = null; private idGenerator: BSBIdGenerator | null = null; private isDisposed = false; private spans = new Map(); private pendingLogs: Array> = []; private flushTimer: NodeJS.Timeout | null = null; constructor(config: BSBObservableConstructor>) { super(config); } public async init(): Promise { // Initialize Axiom client for logs and events if (this.config.enabled.logs || this.config.enabled.metrics) { const axiomOptions: { token: string; orgId?: string; url?: string; } = { token: this.config.axiom.token, }; if (this.config.axiom.orgId) { axiomOptions.orgId = this.config.axiom.orgId; } if (this.config.axiom.url) { axiomOptions.url = this.config.axiom.url; } this.axiom = new AxiomWithoutBatching(axiomOptions); } // Initialize OpenTelemetry for traces (Axiom supports OTLP natively) if (this.config.enabled.traces) { const resource = defaultResource().merge( resourceFromAttributes({ [ATTR_SERVICE_NAME]: this.config.serviceName, ...(this.config.serviceVersion && { [ATTR_SERVICE_VERSION]: this.config.serviceVersion }), ...this.config.resourceAttributes, }) ); // Axiom OTLP endpoint (from docs: https://axiom.co/docs/send-data/opentelemetry) const otlpUrl = this.config.axiom.url ? `${this.config.axiom.url}/v1/traces` : `https://api.axiom.co/v1/traces`; const traceExporter = new OTLPTraceExporter({ url: otlpUrl, headers: { "Authorization": `Bearer ${this.config.axiom.token}`, "X-Axiom-Dataset": this.config.axiom.dataset, }, }); // Create custom ID generator that uses BSB's trace and span IDs this.idGenerator = new BSBIdGenerator(); // Create tracer provider with custom ID generator and span processor // Use shorter batch delay for faster exports during debugging this.tracerProvider = new NodeTracerProvider({ resource, idGenerator: this.idGenerator, spanProcessors: [ new BatchSpanProcessor(traceExporter, { scheduledDelayMillis: 1000, // Export every 1 second (default is 5000) maxQueueSize: 2048, maxExportBatchSize: 512, }) ], }); // Register the provider globally this.tracerProvider.register(); this.tracer = this.tracerProvider.getTracer( this.config.serviceName, this.config.serviceVersion ); } // Start periodic flush for logs if (this.config.enabled.logs) { this.flushTimer = setInterval(() => { this.flushLogs().catch((error) => { console.error("[observable-axiom] Failed to flush logs:", error); }); }, this.config.export.flushIntervalMs); } } public async run(): Promise { // No runtime setup needed } /** * Flush pending logs to Axiom */ private async flushLogs(): Promise { if (!this.axiom || this.pendingLogs.length === 0 || this.isDisposed) { return; } const batch = this.pendingLogs.splice(0, this.config.export.maxBatchSize); try { await this.axiom.ingest(this.config.axiom.dataset, batch); } catch (error) { console.error("[observable-axiom] Failed to ingest logs:", error); // Don't re-add to queue to avoid memory leak } } /** * Add log entry to batch */ private queueLog( level: string, trace: DTrace, pluginName: string, message: string, meta?: LogMeta ): void { if (!this.config.enabled.logs || this.isDisposed) { return; } const formattedMessage = this.logFormatter.formatLog(trace, message, meta); // BSB now generates OpenTelemetry-compliant IDs directly // Trace ID: 32 hex chars, Span ID: 16 hex chars const traceId = trace.t; const spanId = trace.s; const logEntry: Record = { _time: new Date().toISOString(), level: level.toLowerCase(), service: this.config.serviceName, plugin: pluginName, message: formattedMessage, trace_id: traceId, span_id: spanId, }; if (this.config.serviceVersion) { logEntry.version = this.config.serviceVersion; } if (meta && Object.keys(meta).length > 0) { logEntry.meta = meta; } // Add resource attributes for (const [key, value] of Object.entries(this.config.resourceAttributes)) { logEntry[`resource.${key}`] = value; } this.pendingLogs.push(logEntry); // Flush immediately if batch is full if (this.pendingLogs.length >= this.config.export.maxBatchSize) { this.flushLogs().catch((error) => { console.error("[observable-axiom] Failed to flush logs:", error); }); } } // Logging methods // Note: debug() method intentionally not implemented - debug logs too verbose for cloud logging public info(trace: DTrace, pluginName: string, message: string, meta: LogMeta): void { this.queueLog("info", trace, pluginName, message, meta); } public warn(trace: DTrace, pluginName: string, message: string, meta: LogMeta): void { this.queueLog("warn", trace, pluginName, message, meta); } public error(trace: DTrace, pluginName: string, message: string | BSBError, meta?: LogMeta): void { if (message instanceof BSBError) { if (message.raw !== null) { this.queueLog("error", message.raw.trace, pluginName, message.raw.message, message.raw.meta); } else { this.queueLog("error", trace, pluginName, message.message); } } else { this.queueLog("error", trace, pluginName, message, meta); } } // Metrics methods - send as structured events to Axiom public createCounter( timestamp: number, pluginName: string, name: string, description: string, help: string, labels?: string[] ): void { if (!this.config.enabled.metrics || !this.axiom || this.isDisposed) { return; } // Axiom doesn't need metric creation, just send events when values change } public incrementCounter( timestamp: number, pluginName: string, name: string, value: number, labels?: Record ): void { if (!this.config.enabled.metrics || !this.axiom || this.isDisposed) { return; } const event: Record = { _time: new Date(timestamp).toISOString(), metric_type: "counter", service: this.config.serviceName, plugin: pluginName, metric_name: name, value, ...labels, }; this.pendingLogs.push(event); } public createGauge( timestamp: number, pluginName: string, name: string, description: string, help: string, labels?: string[] ): void { // No-op for Axiom } public setGauge( timestamp: number, pluginName: string, name: string, value: number, labels?: Record ): void { if (!this.config.enabled.metrics || !this.axiom || this.isDisposed) { return; } const event: Record = { _time: new Date(timestamp).toISOString(), metric_type: "gauge", service: this.config.serviceName, plugin: pluginName, metric_name: name, value, ...labels, }; this.pendingLogs.push(event); } public createHistogram( timestamp: number, pluginName: string, name: string, description: string, help: string, boundaries?: number[], labels?: string[] ): void { // No-op for Axiom } public observeHistogram( timestamp: number, pluginName: string, name: string, value: number, labels?: Record ): void { if (!this.config.enabled.metrics || !this.axiom || this.isDisposed) { return; } const event: Record = { _time: new Date(timestamp).toISOString(), metric_type: "histogram", service: this.config.serviceName, plugin: pluginName, metric_name: name, value, ...labels, }; this.pendingLogs.push(event); } /** * Check if a trace is internal (should not create spans) */ private isInternalTrace(trace: DTrace): boolean { return trace.t.startsWith('INTERNAL:'); } // Tracing methods - uses OpenTelemetry OTLP export to Axiom public spanStart( timestamp: number, trace: DTrace, pluginName: string, spanName: string, parentSpanId: string | null, attributes?: Record ): void { if (!this.tracer || !this.idGenerator || !this.config.enabled.traces || this.isDisposed) { return; } // Skip internal traces if (this.isInternalTrace(trace)) { return; } // Set the current trace so the ID generator uses BSB's IDs this.idGenerator.setCurrentTrace(trace); // Get parent span context if there's a parent let parentContext; if (parentSpanId) { const parentKey = `${trace.t}:${parentSpanId}`; const parentSpan = this.spans.get(parentKey); if (parentSpan) { parentContext = api.trace.setSpan(api.context.active(), parentSpan); } } const span = this.tracer.startSpan( spanName, { startTime: timestamp, attributes: attributes, }, parentContext ); // Clear the current trace after span creation this.idGenerator.clearCurrentTrace(); const spanKey = `${trace.t}:${trace.s}`; this.spans.set(spanKey, span); } public spanEnd( timestamp: number, trace: DTrace, pluginName: string, attributes?: Record ): void { if (!this.config.enabled.traces || this.isDisposed) { return; } // Skip internal traces if (this.isInternalTrace(trace)) { return; } const spanKey = `${trace.t}:${trace.s}`; const span = this.spans.get(spanKey); if (span) { if (attributes) { span.setAttributes(attributes); } span.end(timestamp); this.spans.delete(spanKey); } } public spanError( timestamp: number, trace: DTrace, pluginName: string, error: Error, attributes?: Record ): void { if (!this.config.enabled.traces || this.isDisposed) { return; } // Skip internal traces if (this.isInternalTrace(trace)) { return; } const spanKey = `${trace.t}:${trace.s}`; const span = this.spans.get(spanKey); if (span) { span.recordException(error, timestamp); span.setStatus({ code: api.SpanStatusCode.ERROR, message: error.message }); if (attributes) { span.setAttributes(attributes); } } } public async dispose(): Promise { // Stop flush timer if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = null; } // Flush remaining logs await this.flushLogs(); this.isDisposed = true; // Clear any active spans (but don't end them - they should already be ended) this.spans.clear(); // Shutdown OpenTelemetry tracer provider (this will flush pending spans) if (this.tracerProvider) { await this.tracerProvider.shutdown(); this.tracerProvider = null; } this.tracer = null; this.idGenerator = null; this.axiom = null; } }