| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { Effect, Metric } from "effect" |
| 3 | import { PrometheusMetrics } from "effect/observability" |
| 4 | |
| 5 | describe("PrometheusMetrics", () => { |
| 6 | describe("format", () => { |
| 7 | describe("Counter", () => { |
| 8 | it.effect("formats a simple counter", () => |
| 9 | Effect.gen(function*() { |
| 10 | const counter = Metric.counter("test_counter", { |
| 11 | description: "A test counter" |
| 12 | }) |
| 13 | |
| 14 | yield* Metric.update(counter, 42) |
| 15 | |
| 16 | const output = yield* PrometheusMetrics.format() |
| 17 | |
| 18 | assert.include(output, "# HELP test_counter A test counter") |
| 19 | assert.include(output, "# TYPE test_counter counter") |
| 20 | assert.include(output, "test_counter 42") |
| 21 | })) |
| 22 | |
| 23 | it.effect("formats a counter with bigint value", () => |
| 24 | Effect.gen(function*() { |
| 25 | const counter = Metric.counter("bigint_counter", { |
| 26 | description: "A bigint counter", |
| 27 | bigint: true |
| 28 | }) |
| 29 | |
| 30 | yield* Metric.update(counter, 9007199254740993n) |
| 31 | |
| 32 | const output = yield* PrometheusMetrics.format() |
| 33 | |
| 34 | assert.include(output, "# TYPE bigint_counter counter") |
| 35 | assert.include(output, "bigint_counter 9007199254740993") |
| 36 | })) |
| 37 | |
| 38 | it.effect("formats a counter with attributes", () => |
| 39 | Effect.gen(function*() { |
| 40 | const counter = Metric.counter("labeled_counter", { |
| 41 | description: "Counter with labels", |
| 42 | attributes: { method: "GET", endpoint: "/api/users" } |
| 43 | }) |
| 44 | |
| 45 | yield* Metric.update(counter, 10) |
| 46 | |
| 47 | const output = yield* PrometheusMetrics.format() |
| 48 | |
| 49 | assert.include(output, "# TYPE labeled_counter counter") |
| 50 | assert.include(output, "labeled_counter{method=\"GET\",endpoint=\"/api/users\"} 10") |
| 51 | })) |
| 52 | |
| 53 | it.effect("formats a counter without description", () => |
| 54 | Effect.gen(function*() { |
| 55 | const counter = Metric.counter("no_desc_counter") |
| 56 | |
| 57 | yield* Metric.update(counter, 5) |
| 58 | |
| 59 | const output = yield* PrometheusMetrics.format() |
| 60 | |
| 61 | assert.notInclude(output, "# HELP no_desc_counter") |
| 62 | assert.include(output, "# TYPE no_desc_counter counter") |
| 63 | assert.include(output, "no_desc_counter 5") |
| 64 | })) |
| 65 | }) |
| 66 | |
| 67 | describe("Gauge", () => { |
| 68 | it.effect("formats a simple gauge", () => |
| 69 | Effect.gen(function*() { |
| 70 | const gauge = Metric.gauge("test_gauge", { |
| 71 | description: "A test gauge" |
| 72 | }) |
| 73 | |
| 74 | yield* Metric.update(gauge, 123.45) |
| 75 | |
| 76 | const output = yield* PrometheusMetrics.format() |
| 77 | |
| 78 | assert.include(output, "# HELP test_gauge A test gauge") |
| 79 | assert.include(output, "# TYPE test_gauge gauge") |
| 80 | assert.include(output, "test_gauge 123.45") |
| 81 | })) |
| 82 | |
| 83 | it.effect("formats a gauge with negative value", () => |
| 84 | Effect.gen(function*() { |
| 85 | const gauge = Metric.gauge("negative_gauge", { |
| 86 | description: "A gauge with negative value" |
| 87 | }) |
| 88 | |
| 89 | yield* Metric.update(gauge, -50) |
| 90 | |
| 91 | const output = yield* PrometheusMetrics.format() |
| 92 | |
| 93 | assert.include(output, "negative_gauge -50") |
| 94 | })) |
| 95 | |
| 96 | it.effect("formats a gauge with bigint value", () => |
| 97 | Effect.gen(function*() { |
| 98 | const gauge = Metric.gauge("bigint_gauge", { |
| 99 | description: "A bigint gauge", |
| 100 | bigint: true |
| 101 | }) |
| 102 | |
| 103 | yield* Metric.update(gauge, 1000000000000n) |
| 104 | |
| 105 | const output = yield* PrometheusMetrics.format() |
| 106 | |
| 107 | assert.include(output, "bigint_gauge 1000000000000") |
| 108 | })) |
| 109 | }) |
| 110 | |
| 111 | describe("Histogram", () => { |
| 112 | it.effect("formats a histogram with buckets", () => |
| 113 | Effect.gen(function*() { |
| 114 | const histogram = Metric.histogram("test_histogram", { |
| 115 | description: "A test histogram", |
| 116 | boundaries: [10, 50, 100, 500] |
| 117 | }) |
| 118 | |
| 119 | yield* Metric.update(histogram, 5) |
| 120 | yield* Metric.update(histogram, 25) |
| 121 | yield* Metric.update(histogram, 75) |
| 122 | yield* Metric.update(histogram, 200) |
| 123 | yield* Metric.update(histogram, 1000) |
| 124 | |
| 125 | const output = yield* PrometheusMetrics.format() |
| 126 | |
| 127 | assert.include(output, "# HELP test_histogram A test histogram") |
| 128 | assert.include(output, "# TYPE test_histogram histogram") |
| 129 | assert.include(output, "test_histogram_bucket{le=\"10\"} 1") |
| 130 | assert.include(output, "test_histogram_bucket{le=\"50\"} 2") |
| 131 | assert.include(output, "test_histogram_bucket{le=\"100\"} 3") |
| 132 | assert.include(output, "test_histogram_bucket{le=\"500\"} 4") |
| 133 | assert.include(output, "test_histogram_bucket{le=\"+Inf\"} 5") |
| 134 | assert.include(output, "test_histogram_sum 1305") |
| 135 | assert.include(output, "test_histogram_count 5") |
| 136 | })) |
| 137 | |
| 138 | it.effect("formats a histogram with attributes", () => |
| 139 | Effect.gen(function*() { |
| 140 | const histogram = Metric.histogram("labeled_histogram", { |
| 141 | description: "Histogram with labels", |
| 142 | boundaries: [100, 500], |
| 143 | attributes: { service: "api" } |
| 144 | }) |
| 145 | |
| 146 | yield* Metric.update(histogram, 250) |
| 147 | |
| 148 | const output = yield* PrometheusMetrics.format() |
| 149 | |
| 150 | assert.include(output, "labeled_histogram_bucket{service=\"api\",le=\"100\"} 0") |
| 151 | assert.include(output, "labeled_histogram_bucket{service=\"api\",le=\"500\"} 1") |
| 152 | assert.include(output, "labeled_histogram_bucket{service=\"api\",le=\"+Inf\"} 1") |
| 153 | assert.include(output, "labeled_histogram_sum{service=\"api\"} 250") |
| 154 | assert.include(output, "labeled_histogram_count{service=\"api\"} 1") |
| 155 | })) |
| 156 | }) |
| 157 | |
| 158 | describe("Summary", () => { |
| 159 | it.effect("formats a summary with quantiles", () => |
| 160 | Effect.gen(function*() { |
| 161 | const summary = Metric.summary("test_summary", { |
| 162 | description: "A test summary", |
| 163 | maxAge: "1 minute", |
| 164 | maxSize: 100, |
| 165 | quantiles: [0.5, 0.9, 0.99] |
| 166 | }) |
| 167 | |
| 168 | // Add enough values to have meaningful quantiles |
| 169 | for (let i = 1; i <= 100; i++) { |
| 170 | yield* Metric.update(summary, i) |
| 171 | } |
| 172 | |
| 173 | const output = yield* PrometheusMetrics.format() |
| 174 | |
| 175 | assert.include(output, "# HELP test_summary A test summary") |
| 176 | assert.include(output, "# TYPE test_summary summary") |
| 177 | assert.include(output, "test_summary{quantile=\"0.5\"}") |
| 178 | assert.include(output, "test_summary{quantile=\"0.9\"}") |
| 179 | assert.include(output, "test_summary{quantile=\"0.99\"}") |
| 180 | assert.include(output, "test_summary_sum 5050") |
| 181 | assert.include(output, "test_summary_count 100") |
| 182 | })) |
| 183 | }) |
| 184 | |
| 185 | describe("Frequency", () => { |
| 186 | it.effect("formats a frequency metric", () => |
| 187 | Effect.gen(function*() { |
| 188 | const frequency = Metric.frequency("test_frequency", { |
| 189 | description: "A test frequency" |
| 190 | }) |
| 191 | |
| 192 | yield* Metric.update(frequency, "success") |
| 193 | yield* Metric.update(frequency, "success") |
| 194 | yield* Metric.update(frequency, "failure") |
| 195 | yield* Metric.update(frequency, "success") |
| 196 | |
| 197 | const output = yield* PrometheusMetrics.format() |
| 198 | |
| 199 | assert.include(output, "# HELP test_frequency A test frequency") |
| 200 | assert.include(output, "# TYPE test_frequency counter") |
| 201 | assert.include(output, "test_frequency{key=\"success\"} 3") |
| 202 | assert.include(output, "test_frequency{key=\"failure\"} 1") |
| 203 | })) |
| 204 | |
| 205 | it.effect("formats a frequency metric with attributes", () => |
| 206 | Effect.gen(function*() { |
| 207 | const frequency = Metric.frequency("labeled_frequency", { |
| 208 | description: "Frequency with labels", |
| 209 | attributes: { service: "api" } |
| 210 | }) |
| 211 | |
| 212 | yield* Metric.update(frequency, "200") |
| 213 | yield* Metric.update(frequency, "404") |
| 214 | |
| 215 | const output = yield* PrometheusMetrics.format() |
| 216 | |
| 217 | assert.include(output, "labeled_frequency{service=\"api\",key=\"200\"} 1") |
| 218 | assert.include(output, "labeled_frequency{service=\"api\",key=\"404\"} 1") |
| 219 | })) |
| 220 | }) |
| 221 | |
| 222 | describe("options", () => { |
| 223 | it.effect("applies prefix to metric names", () => |
| 224 | Effect.gen(function*() { |
| 225 | const counter = Metric.counter("requests_total", { |
| 226 | description: "Total requests" |
| 227 | }) |
| 228 | |
| 229 | yield* Metric.update(counter, 100) |
| 230 | |
| 231 | const output = yield* PrometheusMetrics.format({ prefix: "myapp" }) |
| 232 | |
| 233 | assert.include(output, "# HELP myapp_requests_total Total requests") |
| 234 | assert.include(output, "# TYPE myapp_requests_total counter") |
| 235 | assert.include(output, "myapp_requests_total 100") |
| 236 | })) |
| 237 | |
| 238 | it.effect("applies custom metric name mapper", () => |
| 239 | Effect.gen(function*() { |
| 240 | const counter = Metric.counter("requestCount", { |
| 241 | description: "Request count" |
| 242 | }) |
| 243 | |
| 244 | yield* Metric.update(counter, 50) |
| 245 | |
| 246 | // Convert camelCase to snake_case |
| 247 | const mapper = (name: string) => name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase() |
| 248 | |
| 249 | const output = yield* PrometheusMetrics.format({ metricNameMapper: mapper }) |
| 250 | |
| 251 | assert.include(output, "# HELP request_count Request count") |
| 252 | assert.include(output, "# TYPE request_count counter") |
| 253 | assert.include(output, "request_count 50") |
| 254 | })) |
| 255 | |
| 256 | it.effect("combines prefix and mapper", () => |
| 257 | Effect.gen(function*() { |
| 258 | const counter = Metric.counter("httpRequests", { |
| 259 | description: "HTTP requests" |
| 260 | }) |
| 261 | |
| 262 | yield* Metric.update(counter, 25) |
| 263 | |
| 264 | const mapper = (name: string) => name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase() |
| 265 | |
| 266 | const output = yield* PrometheusMetrics.format({ |
| 267 | prefix: "app", |
| 268 | metricNameMapper: mapper |
| 269 | }) |
| 270 | |
| 271 | assert.include(output, "app_http_requests 25") |
| 272 | })) |
| 273 | }) |
| 274 | |
| 275 | describe("sanitization", () => { |
| 276 | it.effect("sanitizes invalid metric name characters", () => |
| 277 | Effect.gen(function*() { |
| 278 | const counter = Metric.counter("metric-with-dashes.and.dots", { |
| 279 | description: "Metric with special chars" |
| 280 | }) |
| 281 | |
| 282 | yield* Metric.update(counter, 1) |
| 283 | |
| 284 | const output = yield* PrometheusMetrics.format() |
| 285 | |
| 286 | assert.include(output, "metric_with_dashes_and_dots 1") |
| 287 | })) |
| 288 | |
| 289 | it.effect("handles metric names starting with digits", () => |
| 290 | Effect.gen(function*() { |
| 291 | const counter = Metric.counter("123_metric", { |
| 292 | description: "Metric starting with number" |
| 293 | }) |
| 294 | |
| 295 | yield* Metric.update(counter, 1) |
| 296 | |
| 297 | const output = yield* PrometheusMetrics.format() |
| 298 | |
| 299 | assert.include(output, "_123_metric 1") |
| 300 | })) |
| 301 | |
| 302 | it.effect("escapes special characters in label values", () => |
| 303 | Effect.gen(function*() { |
| 304 | const counter = Metric.counter("escape_test", { |
| 305 | description: "Test escaping", |
| 306 | attributes: { |
| 307 | path: "/api/users\"quoted\"", |
| 308 | message: "line1\nline2" |
| 309 | } |
| 310 | }) |
| 311 | |
| 312 | yield* Metric.update(counter, 1) |
| 313 | |
| 314 | const output = yield* PrometheusMetrics.format() |
| 315 | |
| 316 | assert.include(output, "path=\"/api/users\\\"quoted\\\"\"") |
| 317 | assert.include(output, "message=\"line1\\nline2\"") |
| 318 | })) |
| 319 | |
| 320 | it.effect("escapes backslashes in label values", () => |
| 321 | Effect.gen(function*() { |
| 322 | const counter = Metric.counter("backslash_test", { |
| 323 | description: "Test backslash escaping", |
| 324 | attributes: { path: "C:\\Users\\test" } |
| 325 | }) |
| 326 | |
| 327 | yield* Metric.update(counter, 1) |
| 328 | |
| 329 | const output = yield* PrometheusMetrics.format() |
| 330 | |
| 331 | assert.include(output, "path=\"C:\\\\Users\\\\test\"") |
| 332 | })) |
| 333 | |
| 334 | it.effect("escapes special characters in HELP text", () => |
| 335 | Effect.gen(function*() { |
| 336 | const counter = Metric.counter("help_escape", { |
| 337 | description: "Line 1\nLine 2 with\\backslash" |
| 338 | }) |
| 339 | |
| 340 | yield* Metric.update(counter, 1) |
| 341 | |
| 342 | const output = yield* PrometheusMetrics.format() |
| 343 | |
| 344 | assert.include(output, "# HELP help_escape Line 1\\nLine 2 with\\\\backslash") |
| 345 | })) |
| 346 | }) |
| 347 | |
| 348 | describe("special values", () => { |
| 349 | it.effect("formats NaN values", () => |
| 350 | Effect.gen(function*() { |
| 351 | const gauge = Metric.gauge("nan_gauge", { |
| 352 | description: "Gauge with NaN" |
| 353 | }) |
| 354 | |
| 355 | yield* Metric.update(gauge, NaN) |
| 356 | |
| 357 | const output = yield* PrometheusMetrics.format() |
| 358 | |
| 359 | assert.include(output, "nan_gauge NaN") |
| 360 | })) |
| 361 | |
| 362 | it.effect("formats Infinity values", () => |
| 363 | Effect.gen(function*() { |
| 364 | const gauge = Metric.gauge("inf_gauge", { |
| 365 | description: "Gauge with Infinity" |
| 366 | }) |
| 367 | |
| 368 | yield* Metric.update(gauge, Infinity) |
| 369 | |
| 370 | const output = yield* PrometheusMetrics.format() |
| 371 | |
| 372 | assert.include(output, "inf_gauge +Inf") |
| 373 | })) |
| 374 | |
| 375 | it.effect("formats negative Infinity values", () => |
| 376 | Effect.gen(function*() { |
| 377 | const gauge = Metric.gauge("neg_inf_gauge", { |
| 378 | description: "Gauge with -Infinity" |
| 379 | }) |
| 380 | |
| 381 | yield* Metric.update(gauge, -Infinity) |
| 382 | |
| 383 | const output = yield* PrometheusMetrics.format() |
| 384 | |
| 385 | assert.include(output, "neg_inf_gauge -Inf") |
| 386 | })) |
| 387 | }) |
| 388 | |
| 389 | describe("empty output", () => { |
| 390 | it.effect("returns empty string when no metrics are registered", () => |
| 391 | Effect.gen(function*() { |
| 392 | const output = yield* PrometheusMetrics.format() |
| 393 | |
| 394 | // Output may contain other metrics from the test environment, |
| 395 | // but at minimum it should be a valid string |
| 396 | assert.isString(output) |
| 397 | })) |
| 398 | }) |
| 399 | |
| 400 | describe("output format", () => { |
| 401 | it.effect("ends with a newline when there are metrics", () => |
| 402 | Effect.gen(function*() { |
| 403 | const counter = Metric.counter("newline_test", { |
| 404 | description: "Test newline" |
| 405 | }) |
| 406 | |
| 407 | yield* Metric.update(counter, 1) |
| 408 | |
| 409 | const output = yield* PrometheusMetrics.format() |
| 410 | |
| 411 | if (output.length > 0) { |
| 412 | assert.isTrue(output.endsWith("\n")) |
| 413 | } |
| 414 | })) |
| 415 | }) |
| 416 | }) |
| 417 | }) |