| 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 | */ |
| 11 | import type * as Context from "../Context.ts" |
| 12 | import * as Effect from "../Effect.ts" |
| 13 | import * as HttpRouter from "../http/HttpRouter.ts" |
| 14 | import * as HttpServerResponse from "../http/HttpServerResponse.ts" |
| 15 | import * as Layer from "../Layer.ts" |
| 16 | import * 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 | */ |
| 37 | export 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 | */ |
| 46 | export 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 | */ |
| 65 | export 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 | */ |
| 108 | export 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 | */ |
| 129 | export 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 | */ |
| 191 | export 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 | */ |
| 217 | const 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 | */ |
| 235 | const 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 | */ |
| 251 | const 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 | */ |
| 262 | const 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 | */ |
| 272 | const 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 | */ |
| 292 | const 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 | */ |
| 320 | const 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 | */ |
| 338 | const 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 | */ |
| 363 | const 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 | */ |
| 390 | const 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 | */ |
| 403 | const 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 | */ |
| 417 | const 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 | */ |
| 446 | const 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 | */ |
| 472 | const 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 | } |