1import { assert, describe, it } from "@effect/vitest"
2import { Effect, Metric } from "effect"
3import { PrometheusMetrics } from "effect/observability"
4
5describe("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})