1/**
2 * Formats Effect metrics for Prometheus.
3 *
4 * This module reads metrics from the current Effect context and renders them in
5 * the Prometheus text format. It can also register a pull-based HTTP endpoint,
6 * such as `/metrics`, for Prometheus to scrape.
7 *
8 * @stability unstable
9 * @since 4.0.0
10 */
11import type * as Context from "../Context.ts"
12import * as Effect from "../Effect.ts"
13import * as HttpRouter from "../http/HttpRouter.ts"
14import * as HttpServerResponse from "../http/HttpServerResponse.ts"
15import * as Layer from "../Layer.ts"
16import * as Metric from "../Metric.ts"
17
18/**
19 * A function that transforms metric names before formatting.
20 *
21 * **Example** (Mapping metric names)
22 *
23 * ```ts import.meta.vitest
24 * import type { PrometheusMetrics } from "effect/observability"
25 *
26 * // Convert camelCase to snake_case
27 * const mapper: PrometheusMetrics.MetricNameMapper = (name) =>
28 * name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()
29 *
30 * mapper("httpRequests") // => "http_requests"
31 * ```
32 *
33 * @stability unstable
34 * @category models
35 * @since 4.0.0
36 */
37export type MetricNameMapper = (name: string) => string
38
39/**
40 * Options for formatting metrics.
41 *
42 * @stability unstable
43 * @category options
44 * @since 4.0.0
45 */
46export interface FormatOptions {
47 /**
48 * Optional prefix to prepend to all metric names.
49 * The prefix will be sanitized and joined with an underscore.
50 */
51 readonly prefix?: string | undefined
52 /**
53 * Optional function to transform metric names before sanitization.
54 */
55 readonly metricNameMapper?: MetricNameMapper | undefined
56}
57
58/**
59 * Options for exporting Prometheus metrics over HTTP.
60 *
61 * @stability unstable
62 * @category options
63 * @since 4.0.0
64 */
65export interface HttpOptions extends FormatOptions {
66 /**
67 * The path to the HTTP route on which Prometheus metrics should be served.
68 */
69 readonly path?: HttpRouter.PathInput | undefined
70}
71
72/**
73 * Formats all metrics in the registry to Prometheus exposition format.
74 *
75 * **Example** (Formatting metrics)
76 *
77 * ```ts import.meta.vitest
78 * import { Effect, Metric } from "effect"
79 * import { PrometheusMetrics } from "effect/observability"
80 *
81 * const program = Effect.gen(function*() {
82 * const counter = Metric.counter("api_requests_total", {
83 * description: "Total API requests"
84 * })
85 * const gauge = Metric.gauge("active_connections", {
86 * description: "Number of active connections"
87 * })
88 *
89 * yield* Metric.update(counter, 100)
90 * yield* Metric.update(gauge, 25)
91 *
92 * // Format without prefix
93 * const output1 = yield* PrometheusMetrics.format()
94 *
95 * // Format with prefix
96 * const output2 = yield* PrometheusMetrics.format({ prefix: "myapp" })
97 *
98 * return [output1.includes("api_requests_total"), output2.includes("myapp_active_connections")]
99 * })
100 *
101 * Effect.runSync(program) // => [true, true]
102 * ```
103 *
104 * @stability unstable
105 * @category formatting
106 * @since 4.0.0
107 */
108export const format: (options?: FormatOptions | undefined) => Effect.Effect<string> = Effect.fnUntraced(
109 function*(options) {
110 const services = yield* Effect.context<never>()
111 return formatUnsafe(services, options)
112 }
113)
114
115/**
116 * Formats all metrics in the registry to Prometheus exposition format synchronously.
117 *
118 * **When to use**
119 *
120 * Use when you already have access to the context and need low-level
121 * synchronous formatting.
122 *
123 * @see {@link format} for effectful formatting from the current context
124 *
125 * @stability unstable
126 * @category formatting
127 * @since 4.0.0
128 */
129export const formatUnsafe = (
130 context: Context.Context<never>,
131 options?: FormatOptions | undefined
132): string => {
133 const snapshot = Metric.snapshotUnsafe(context)
134 const prefix = options?.prefix ? sanitizeMetricName(options.prefix) + "_" : ""
135 const mapper = options?.metricNameMapper ?? ((name: string) => name)
136 const lines: Array<string> = []
137
138 // Group metrics by base name for proper TYPE/HELP declarations
139 const metricsByName = new Map<string, Array<Metric.Metric.Snapshot>>()
140 for (let i = 0; i < snapshot.length; i++) {
141 const metric = snapshot[i]
142 const name = prefix + sanitizeMetricName(mapper(metric.id))
143 const existing = metricsByName.get(name)
144 if (existing) {
145 existing.push(metric)
146 } else {
147 metricsByName.set(name, [metric])
148 }
149 }
150
151 for (const [name, metrics] of metricsByName) {
152 formatMetricFamily(name, metrics, lines)
153 }
154
155 // Prometheus expects a trailing newline if there's content
156 return lines.length > 0 ? lines.join("\n") + "\n" : ""
157}
158
159/**
160 * Creates a Layer that registers a `/metrics` HTTP endpoint for Prometheus
161 * scraping.
162 *
163 * **Details**
164 *
165 * This layer automatically adds a GET route to your HTTP router that serves
166 * metrics in Prometheus exposition format. By default, the endpoint is
167 * registered at `/metrics`, but this can be customized via the `path` option.
168 *
169 * **Example** (Serving metrics over HTTP)
170 *
171 * ```ts import.meta.vitest
172 * import { Layer } from "effect"
173 * import { PrometheusMetrics } from "effect/observability"
174 *
175 * // Create a layer that adds /metrics endpoint to the router
176 * const PrometheusLayer = PrometheusMetrics.layerHttp()
177 *
178 * // Or customize the path and add a prefix to all metric names
179 * const CustomPrometheusLayer = PrometheusMetrics.layerHttp({
180 * path: "/prometheus/metrics",
181 * prefix: "myapp"
182 * })
183 *
184 * const result = [Layer.isLayer(PrometheusLayer), Layer.isLayer(CustomPrometheusLayer)] // => [true, true]
185 * ```
186 *
187 * @stability unstable
188 * @category layers
189 * @since 4.0.0
190 */
191export const layerHttp = (
192 options?: HttpOptions | undefined
193): Layer.Layer<never, never, HttpRouter.HttpRouter> =>
194 Layer.effectDiscard(Effect.gen(function*() {
195 const router = yield* HttpRouter.HttpRouter
196
197 const { path, ...formatOptions } = options ?? {}
198
199 const handler = Effect.gen(function*() {
200 const body = yield* format(formatOptions)
201 return HttpServerResponse.text(body, {
202 contentType: "text/plain; version=0.0.4; charset=utf-8"
203 })
204 })
205
206 yield* router.add("GET", path ?? "/metrics", handler)
207 }))
208
209// -----------------------------------------------------------------------------
210// Internal
211// -----------------------------------------------------------------------------
212
213/**
214 * Sanitize a metric name to conform to Prometheus naming rules.
215 * Valid characters: [a-zA-Z_:][a-zA-Z0-9_:]*
216 */
217const sanitizeMetricName = (name: string): string => {
218 // Replace invalid characters with underscores
219 let sanitized = name.replace(/[^a-zA-Z0-9_:]/g, "_")
220 // Ensure it starts with a letter or underscore (not a digit or colon)
221 if (/^[0-9:]/.test(sanitized)) {
222 sanitized = "_" + sanitized
223 }
224 // Remove consecutive underscores
225 sanitized = sanitized.replace(/_+/g, "_")
226 // Remove trailing underscores
227 sanitized = sanitized.replace(/_$/, "")
228 return sanitized
229}
230
231/**
232 * Sanitize a label name to conform to Prometheus naming rules.
233 * Valid characters: [a-zA-Z_][a-zA-Z0-9_]*
234 */
235const sanitizeLabelName = (name: string): string => {
236 // Replace invalid characters with underscores
237 let sanitized = name.replace(/[^a-zA-Z0-9_]/g, "_")
238 // Ensure it starts with a letter or underscore
239 if (/^[0-9]/.test(sanitized)) {
240 sanitized = "_" + sanitized
241 }
242 // Remove consecutive underscores
243 sanitized = sanitized.replace(/_+/g, "_")
244 return sanitized
245}
246
247/**
248 * Escape special characters in label values.
249 * Backslash, double-quote, and newline must be escaped.
250 */
251const escapeLabelValue = (value: string): string => {
252 return value
253 .replace(/\\/g, "\\\\")
254 .replace(/"/g, "\\\"")
255 .replace(/\n/g, "\\n")
256}
257
258/**
259 * Escape special characters in HELP text.
260 * Backslash and newline must be escaped.
261 */
262const escapeHelp = (text: string): string => {
263 return text
264 .replace(/\\/g, "\\\\")
265 .replace(/\n/g, "\\n")
266}
267
268/**
269 * Format a numeric value for Prometheus output.
270 * Handles special values like NaN and Infinity.
271 */
272const formatValue = (value: number | bigint): string => {
273 if (typeof value === "bigint") {
274 return value.toString()
275 }
276 if (Number.isNaN(value)) {
277 return "NaN"
278 }
279 if (value === Infinity) {
280 return "+Inf"
281 }
282 if (value === -Infinity) {
283 return "-Inf"
284 }
285 return value.toString()
286}
287
288/**
289 * Format labels as a Prometheus label string.
290 * Returns empty string if no labels, otherwise returns {label1="value1",label2="value2"}
291 */
292const formatLabels = (
293 attributes: Metric.Metric.AttributeSet | undefined,
294 extraLabels?: Array<[string, string]>
295): string => {
296 const labels: Array<string> = []
297
298 // Add metric attributes as labels
299 if (attributes) {
300 for (const [key, value] of Object.entries(attributes)) {
301 // Skip internal attributes like "unit" and "time_unit"
302 if (key === "unit" || key === "time_unit") continue
303 labels.push(`${sanitizeLabelName(key)}="${escapeLabelValue(value)}"`)
304 }
305 }
306
307 // Add extra labels (e.g., for histogram buckets, summary quantiles)
308 if (extraLabels) {
309 for (const [key, value] of extraLabels) {
310 labels.push(`${sanitizeLabelName(key)}="${escapeLabelValue(value)}"`)
311 }
312 }
313
314 return labels.length > 0 ? `{${labels.join(",")}}` : ""
315}
316
317/**
318 * Map Effect metric type to Prometheus metric type string.
319 */
320const mapMetricType = (type: Metric.Metric.Type): string => {
321 switch (type) {
322 case "Counter":
323 return "counter"
324 case "Gauge":
325 return "gauge"
326 case "Histogram":
327 return "histogram"
328 case "Summary":
329 return "summary"
330 case "Frequency":
331 return "counter"
332 }
333}
334
335/**
336 * Format a metric family (all metrics with the same name).
337 */
338const formatMetricFamily = (
339 name: string,
340 metrics: Array<Metric.Metric.Snapshot>,
341 lines: Array<string>
342): void => {
343 const first = metrics[0]
344 const prometheusType = mapMetricType(first.type)
345
346 // HELP line (only if description exists)
347 if (first.description) {
348 lines.push(`# HELP ${name} ${escapeHelp(first.description)}`)
349 }
350
351 // TYPE line
352 lines.push(`# TYPE ${name} ${prometheusType}`)
353
354 // Data lines for each metric instance
355 for (let i = 0; i < metrics.length; i++) {
356 formatMetricData(name, metrics[i], lines)
357 }
358}
359
360/**
361 * Format data lines for a single metric snapshot.
362 */
363const formatMetricData = (
364 name: string,
365 metric: Metric.Metric.Snapshot,
366 lines: Array<string>
367): void => {
368 switch (metric.type) {
369 case "Counter":
370 formatCounter(name, metric, lines)
371 break
372 case "Gauge":
373 formatGauge(name, metric, lines)
374 break
375 case "Histogram":
376 formatHistogram(name, metric, lines)
377 break
378 case "Summary":
379 formatSummary(name, metric, lines)
380 break
381 case "Frequency":
382 formatFrequency(name, metric, lines)
383 break
384 }
385}
386
387/**
388 * Format a Counter metric.
389 */
390const formatCounter = (
391 name: string,
392 metric: Metric.Metric.SnapshotProto<"Counter", Metric.CounterState<number | bigint>>,
393 lines: Array<string>
394): void => {
395 const labels = formatLabels(metric.attributes)
396 const value = formatValue(metric.state.count)
397 lines.push(`${name}${labels} ${value}`)
398}
399
400/**
401 * Format a Gauge metric.
402 */
403const formatGauge = (
404 name: string,
405 metric: Metric.Metric.SnapshotProto<"Gauge", Metric.GaugeState<number | bigint>>,
406 lines: Array<string>
407): void => {
408 const labels = formatLabels(metric.attributes)
409 const value = formatValue(metric.state.value)
410 lines.push(`${name}${labels} ${value}`)
411}
412
413/**
414 * Format a Histogram metric.
415 * Produces _bucket, _sum, and _count lines.
416 */
417const formatHistogram = (
418 name: string,
419 metric: Metric.Metric.SnapshotProto<"Histogram", Metric.HistogramState>,
420 lines: Array<string>
421): void => {
422 const state = metric.state
423
424 // Format bucket lines
425 // Effect buckets are [boundary, cumulativeCount] pairs
426 for (let i = 0; i < state.buckets.length; i++) {
427 const [boundary, cumulativeCount] = state.buckets[i]
428 const bucketLabels = formatLabels(metric.attributes, [["le", boundary.toString()]])
429 lines.push(`${name}_bucket${bucketLabels} ${cumulativeCount}`)
430 }
431
432 // Add +Inf bucket (total count)
433 const infLabels = formatLabels(metric.attributes, [["le", "+Inf"]])
434 lines.push(`${name}_bucket${infLabels} ${state.count}`)
435
436 // Sum and count
437 const baseLabels = formatLabels(metric.attributes)
438 lines.push(`${name}_sum${baseLabels} ${formatValue(state.sum)}`)
439 lines.push(`${name}_count${baseLabels} ${state.count}`)
440}
441
442/**
443 * Format a Summary metric.
444 * Produces quantile lines, _sum, and _count lines.
445 */
446const formatSummary = (
447 name: string,
448 metric: Metric.Metric.SnapshotProto<"Summary", Metric.SummaryState>,
449 lines: Array<string>
450): void => {
451 const state = metric.state
452
453 // Format quantile lines
454 for (let i = 0; i < state.quantiles.length; i++) {
455 const [quantile, value] = state.quantiles[i]
456 // Only output quantiles with defined values
457 if (value !== undefined) {
458 const quantileLabels = formatLabels(metric.attributes, [["quantile", quantile.toString()]])
459 lines.push(`${name}${quantileLabels} ${formatValue(value)}`)
460 }
461 }
462
463 // Sum and count
464 const baseLabels = formatLabels(metric.attributes)
465 lines.push(`${name}_sum${baseLabels} ${formatValue(state.sum)}`)
466 lines.push(`${name}_count${baseLabels} ${state.count}`)
467}
468
469/**
470 * Format a Frequency metric as a counter with key labels.
471 */
472const formatFrequency = (
473 name: string,
474 metric: Metric.Metric.SnapshotProto<"Frequency", Metric.FrequencyState>,
475 lines: Array<string>
476): void => {
477 const state = metric.state
478
479 // Each occurrence becomes a separate line with a "key" label
480 for (const [key, count] of state.occurrences) {
481 const labels = formatLabels(metric.attributes, [["key", key]])
482 lines.push(`${name}${labels} ${count}`)
483 }
484}