/** * @factory/observability — Prometheus text exposition for the app `/metrics` * endpoints (TASK-0135). * * Every app exposes its spec metrics in Prometheus text format on its OWN * port (the scrape config targets each app's `/metrics`). This module renders * the process-global `MeterProvider` (the one `initOtel` registers, which the * `metrics` singleton instruments live on) into Prometheus text. * * Why not the exporter's own HTTP server? `PrometheusExporter` wants to run * its own `http.Server` on a dedicated port — but each app already owns its * port (api :4000, worker :4003, realtime :4001, web :3000) and Prometheus * must scrape the SAME port the app serves. So the reader is attached to the * MeterProvider with `preventServerStart: true` (no server of its own), and * each app mounts `renderPrometheus()` on a `/metrics` route. When OTel is * disabled there is no MeterProvider → `renderPrometheus()` returns "" and * the app's `/metrics` route serves its fallback (the realtime app's own * registry; the others a 204/503). * * OTel JS 2.x surface (T-190b): `PrometheusExporter extends MetricReader`; * with `preventServerStart` it is a pure pull-reader and its `collect()` * (inherited from `MetricReader`) returns the `CollectionResult` we serialize * with the package's `PrometheusSerializer` (0.223.0: 5-arg constructor * `(prefix?, appendTimestamp?, withResourceConstantLabels?, withoutTargetInfo?, * withoutScopeInfo?)`, `serialize(resourceMetrics)`). We pass * `withoutScopeInfo: true` because the 2.x default inlines `otel_scope_name` * into every data point's labels, which would fragment the bare-metric-name * contract the dashboard-completeness script greps. The leading `target_info` * gauge line is harmless for scrapers, and the dashboards never render it. */ import { metrics as metricsApi } from "@opentelemetry/api"; import type { MeterProvider as SdkMeterProvider } from "@opentelemetry/sdk-metrics"; import type { PrometheusExporter } from "@opentelemetry/exporter-prometheus"; /** * The Prometheus reader `initOtel` attaches (server-less). Kept so * `renderPrometheus()` can `collect()` from the live MeterProvider. Exported * for the conformance tests; `undefined` when OTel is disabled. */ export let prometheusReader: PrometheusExporter | undefined; /** Set by `initOtel` when enabled (undefined stays when disabled). */ export function setPrometheusReader(reader: PrometheusExporter | undefined): void { prometheusReader = reader; } /** * Render the process-global metrics as Prometheus text (version 0.0.4). * * Returns "" when OTel is disabled (no MeterProvider / no reader) — callers * treat that as "nothing to scrape" and serve their own fallback. Never * throws: a render failure degrades to "" (a bad scrape must not take down a * live app's /metrics handler). */ export async function renderPrometheus(): Promise { if (!prometheusReader) return ""; try { const { PrometheusSerializer } = await import("@opentelemetry/exporter-prometheus"); const { resourceMetrics } = await prometheusReader.collect(); // 0.223.0 serializer: (prefix?, appendTimestamp?, withResourceConstantLabels?, // withoutTargetInfo?, withoutScopeInfo?). No prefix, no timestamp, target info // kept; withoutScopeInfo=true keeps data points at bare metric names (the 2.x // default would inline otel_scope_name and break the dashboard grep contract). const serializer = new PrometheusSerializer(undefined, false, undefined, false, true); return serializer.serialize(resourceMetrics); } catch { return ""; } } /** * True when the global MeterProvider is the SDK one (i.e. OTel enabled and * metrics will be rendered). Convenience for an app's /metrics route to decide * whether to serve OTel text or its fallback. */ export function hasPrometheusReader(): boolean { return prometheusReader !== undefined; } // Re-export for apps that want to build a reader themselves (advanced use). export type { SdkMeterProvider, PrometheusExporter }; export { metricsApi };