/** * OpenTelemetry setup for verse-quote-app: exports traces, metrics, and logs * over OTLP/HTTP to the shared Grafana LGTM collector on this VM * (http://localhost:4318). Manual instrumentation rather than * auto-instrumentation-node — this app's ESM/Nitro entrypoint doesn't give * us a require-before-everything-else hook to patch http/pg at, so we * instrument the one seam that matters for this app: action calls. */ import { trace, metrics, diag, DiagConsoleLogger, DiagLogLevel, type Span } from "@opentelemetry/api"; import { logs, SeverityNumber } from "@opentelemetry/api-logs"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import { BasicTracerProvider, BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { MeterProvider, PeriodicExportingMetricReader, } from "@opentelemetry/sdk-metrics"; import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; import { LoggerProvider, BatchLogRecordProcessor, } from "@opentelemetry/sdk-logs"; import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; const SERVICE_NAME = process.env.OTEL_SERVICE_NAME || "verse-quote-app"; const OTLP_ENDPOINT = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318"; let started = false; export function startOtel(): void { if (started) return; started = true; if (process.env.OTEL_DEBUG) { diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG); } const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: SERVICE_NAME, }); const tracerProvider = new BasicTracerProvider({ resource, spanProcessors: [ new BatchSpanProcessor( new OTLPTraceExporter({ url: `${OTLP_ENDPOINT}/v1/traces` }), ), ], }); trace.setGlobalTracerProvider(tracerProvider); const meterProvider = new MeterProvider({ resource, readers: [ new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter({ url: `${OTLP_ENDPOINT}/v1/metrics` }), exportIntervalMillis: 15000, }), ], }); metrics.setGlobalMeterProvider(meterProvider); const loggerProvider = new LoggerProvider({ resource, processors: [ new BatchLogRecordProcessor({ exporter: new OTLPLogExporter({ url: `${OTLP_ENDPOINT}/v1/logs` }), }), ], }); logs.setGlobalLoggerProvider(loggerProvider); const shutdown = () => { Promise.allSettled([ tracerProvider.shutdown(), meterProvider.shutdown(), loggerProvider.shutdown(), ]).finally(() => process.exit(0)); }; process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); console.log(`[otel] started, exporting to ${OTLP_ENDPOINT} as service "${SERVICE_NAME}"`); } // Must run before getMeter()/createCounter() below: unlike the tracer and // logger proxies (which re-resolve their delegate on every call), the metrics // API's Counter is bound to whatever provider is active at createCounter() // time — calling startOtel() after creating counters would permanently bind // them to the no-op backend and silently drop every metric. startOtel(); const tracer = trace.getTracer(SERVICE_NAME); const meter = metrics.getMeter(SERVICE_NAME); const otelLogger = logs.getLogger(SERVICE_NAME); export const actionCallCounter = meter.createCounter("verse_quote_action_calls_total", { description: "Number of verse-quote-app action invocations, by action name and outcome", }); export const quoteFavoriteCounter = meter.createCounter("verse_quote_favorites_total", { description: "Number of favorite/unfavorite toggles on quotes", }); /** * Wrap an action's run() body in a span + call counter + structured log. * Records success/error outcome and re-throws so defineAction's own error * handling is unaffected. */ export async function withActionSpan( actionName: string, attributes: Record, fn: (span: Span) => Promise, ): Promise { return tracer.startActiveSpan(`action.${actionName}`, async (span) => { for (const [key, value] of Object.entries(attributes)) { if (value !== undefined) span.setAttribute(key, value); } const start = Date.now(); try { const result = await fn(span); actionCallCounter.add(1, { action: actionName, outcome: "success" }); otelLogger.emit({ severityNumber: SeverityNumber.INFO, severityText: "INFO", body: `action ${actionName} succeeded in ${Date.now() - start}ms`, attributes: { action: actionName, ...attributes }, }); return result; } catch (err) { actionCallCounter.add(1, { action: actionName, outcome: "error" }); span.recordException(err instanceof Error ? err : new Error(String(err))); otelLogger.emit({ severityNumber: SeverityNumber.ERROR, severityText: "ERROR", body: `action ${actionName} failed: ${err instanceof Error ? err.message : String(err)}`, attributes: { action: actionName, ...attributes }, }); throw err; } finally { span.end(); } }); }