1import * as OtelMetrics from "@effect/opentelemetry/OtelMetrics"
2import * as Resource from "@effect/opentelemetry/Resource"
3import { assert, describe, it, vi } from "@effect/vitest"
4import { Duration, Fiber, Layer, Metric, Ref, String } from "effect"
5import * as Effect from "effect/Effect"
6import { HttpClient, type HttpClientError, HttpClientResponse } from "effect/http"
7import { OtlpExporter, OtlpMetrics, OtlpSerialization } from "effect/observability"
8import { TestClock } from "effect/testing"
9
10const attributes = { x: "a", y: "b" }
11
12describe("Metric", () => {
13 const series = Effect.map(
14 Metric.snapshot,
15 (snapshots) =>
16 snapshots
17 .map((snapshot) => [snapshot.attributes ?? null, "count" in snapshot.state ? snapshot.state.count : null])
18 .sort((a, b) => JSON.stringify(a[0]) < JSON.stringify(b[0]) ? -1 : 1)
19 )
20
21 it.effect("keeps shared metrics scoped to the active registry", () =>
22 Effect.gen(function*() {
23 const counter = Metric.counter(nextId())
24 const registryA: Metric.MetricRegistry = new Map()
25 const registryB: Metric.MetricRegistry = new Map()
26
27 yield* Metric.update(counter, 1).pipe(Effect.provideService(Metric.MetricRegistry, registryA))
28 yield* Metric.update(counter, 10).pipe(Effect.provideService(Metric.MetricRegistry, registryB))
29 yield* Metric.update(counter, 2).pipe(Effect.provideService(Metric.MetricRegistry, registryA))
30
31 const valueA = yield* Metric.value(counter).pipe(Effect.provideService(Metric.MetricRegistry, registryA))
32 const valueB = yield* Metric.value(counter).pipe(Effect.provideService(Metric.MetricRegistry, registryB))
33 assert.deepStrictEqual(valueA, { count: 3, incremental: false })
34 assert.deepStrictEqual(valueB, { count: 10, incremental: false })
35 }))
36
37 it.effect("keeps attributed metrics scoped to the active registry", () =>
38 Effect.gen(function*() {
39 const counter = Metric.counter(nextId()).pipe(Metric.withAttributes(attributes))
40 const registryA: Metric.MetricRegistry = new Map()
41 const registryB: Metric.MetricRegistry = new Map()
42
43 yield* Metric.update(counter, 1).pipe(Effect.provideService(Metric.MetricRegistry, registryA))
44 yield* Metric.update(counter, 10).pipe(Effect.provideService(Metric.MetricRegistry, registryB))
45
46 const valueA = yield* Metric.value(counter).pipe(Effect.provideService(Metric.MetricRegistry, registryA))
47 const valueB = yield* Metric.value(counter).pipe(Effect.provideService(Metric.MetricRegistry, registryB))
48 assert.strictEqual(valueA.count, 1)
49 assert.strictEqual(valueB.count, 10)
50 }))
51
52 it.effect("registers an unattributed metric again after the registry is cleared", () => {
53 const registry: Metric.MetricRegistry = new Map()
54 return Effect.gen(function*() {
55 const counter = Metric.counter(nextId())
56
57 yield* Metric.update(counter, 1)
58 registry.clear()
59 yield* Metric.update(counter, 10)
60
61 const snapshots = yield* Metric.snapshot
62 assert.deepStrictEqual(snapshots.map((snapshot) => snapshot.state), [{ count: 10, incremental: false }])
63 assert.strictEqual((yield* Metric.value(counter)).count, 10)
64 }).pipe(Effect.provideService(Metric.MetricRegistry, registry))
65 })
66
67 it.effect("shares one series between no attributes and empty attributes", () =>
68 Effect.gen(function*() {
69 const id = nextId()
70 const first = Metric.counter(id)
71 const second = Metric.counter(id, { attributes: {} })
72 const third = Metric.counter(id, { attributes: [] })
73
74 yield* Metric.update(first, 1)
75 yield* Metric.update(second, 10)
76 yield* Metric.update(third, 100)
77
78 assert.strictEqual((yield* Metric.snapshot).length, 1)
79 assert.strictEqual((yield* Metric.value(first)).count, 111)
80 }).pipe(Effect.provideService(Metric.MetricRegistry, new Map())))
81
82 it.effect("keeps distinct attribute sets in separate series", () =>
83 Effect.gen(function*() {
84 const id = nextId()
85 const first = Metric.counter(id, { attributes: { a: "b,c=d" } })
86 const second = Metric.counter(id, { attributes: { a: "b", c: "d" } })
87
88 yield* Metric.update(first, 1)
89 yield* Metric.update(second, 10)
90
91 assert.strictEqual((yield* Metric.value(first)).count, 1)
92 assert.strictEqual((yield* Metric.value(second)).count, 10)
93 }))
94
95 it.effect("keeps an id containing a colon apart from an id with a description", () =>
96 Effect.gen(function*() {
97 const id = nextId()
98 const first = Metric.counter(`${id}:requests`)
99 const second = Metric.counter(id, { description: "requests" })
100
101 yield* Metric.update(first, 1)
102 yield* Metric.update(second, 10)
103
104 assert.strictEqual((yield* Metric.value(first)).count, 1)
105 assert.strictEqual((yield* Metric.value(second)).count, 10)
106 assert.strictEqual((yield* Metric.snapshot).length, 2)
107 }).pipe(Effect.provideService(Metric.MetricRegistry, new Map())))
108
109 it.effect("keeps a description apart from attributes", () =>
110 Effect.gen(function*() {
111 const id = nextId()
112 const first = Metric.counter(id, { description: `[["route","/users"]]` })
113 const second = Metric.counter(id, { attributes: { route: "/users" } })
114
115 yield* Metric.update(first, 1)
116 yield* Metric.update(second, 10)
117
118 assert.strictEqual((yield* Metric.value(first)).count, 1)
119 assert.strictEqual((yield* Metric.value(second)).count, 10)
120 assert.strictEqual((yield* Metric.snapshot).length, 2)
121 }).pipe(Effect.provideService(Metric.MetricRegistry, new Map())))
122
123 it.effect("keeps equal attribute names with different values in separate series", () =>
124 Effect.gen(function*() {
125 const id = nextId()
126 const first = Metric.counter(id, { attributes: { route: "/users", method: "GET" } })
127 const second = Metric.counter(id, { attributes: { route: "/users", method: "POST" } })
128
129 yield* Metric.update(first, 1)
130 yield* Metric.update(second, 10)
131
132 assert.strictEqual((yield* Metric.value(first)).count, 1)
133 }))
134
135 it.effect("shares a series for equal attributes in different record orders", () =>
136 Effect.gen(function*() {
137 const id = nextId()
138 const first = Metric.counter(id, { attributes: { route: "/users", method: "GET" } })
139 const second = Metric.counter(id, { attributes: { method: "GET", route: "/users" } })
140
141 yield* Metric.update(first, 1)
142 yield* Metric.update(second, 10)
143
144 assert.strictEqual((yield* Metric.value(first)).count, 11)
145 }))
146
147 it.effect("follows contextual attributes that change between updates of a plain metric", () =>
148 Effect.gen(function*() {
149 const counter = Metric.counter(nextId())
150 const tenantA = { tenant: "a" }
151
152 yield* Metric.update(counter, 1).pipe(Effect.provideService(Metric.CurrentMetricAttributes, tenantA))
153 yield* Metric.update(counter, 10).pipe(Effect.provideService(Metric.CurrentMetricAttributes, { tenant: "b" }))
154 yield* Metric.update(counter, 100).pipe(Effect.provideService(Metric.CurrentMetricAttributes, tenantA))
155 yield* Metric.update(counter, 1000)
156
157 assert.deepStrictEqual(yield* series, [[null, 1000], [{ tenant: "a" }, 101], [{ tenant: "b" }, 10]])
158 }).pipe(Effect.provideService(Metric.MetricRegistry, new Map())))
159
160 it.effect("shares a series after merging metric and contextual attributes", () =>
161 Effect.gen(function*() {
162 const id = nextId()
163 const first = Metric.counter(id, { attributes: { route: "/users" } })
164 const second = Metric.counter(id, { attributes: { method: "GET" } })
165 const firstContext = { method: "GET" }
166
167 yield* Metric.update(first, 1).pipe(Effect.provideService(Metric.CurrentMetricAttributes, firstContext))
168 yield* Metric.update(second, 10).pipe(
169 Effect.provideService(Metric.CurrentMetricAttributes, { route: "/users" })
170 )
171
172 assert.strictEqual(
173 (yield* Metric.value(first).pipe(Effect.provideService(Metric.CurrentMetricAttributes, firstContext))).count,
174 11
175 )
176 }))
177
178 it.effect("preserves attribute order in snapshots", () =>
179 Effect.gen(function*() {
180 const metric = Metric.counter(nextId(), { attributes: { route: "/users", method: "GET" } })
181
182 yield* Metric.update(metric, 1)
183 const snapshot = yield* Metric.snapshot
184
185 assert.deepStrictEqual(Object.keys(snapshot[0].attributes ?? {}), ["route", "method"])
186 }).pipe(Effect.provideService(Metric.MetricRegistry, new Map())))
187
188 describe("withAttributes", () => {
189 it.effect("follows the contextual attributes when they change between updates", () =>
190 Effect.gen(function*() {
191 const counter = Metric.withAttributes(Metric.counter(nextId()), { route: "/users" })
192
193 yield* Metric.update(counter, 1)
194 yield* Metric.update(counter, 10).pipe(Effect.provideService(Metric.CurrentMetricAttributes, { tenant: "a" }))
195 yield* Metric.update(counter, 100).pipe(Effect.provideService(Metric.CurrentMetricAttributes, { tenant: "b" }))
196 yield* Metric.update(counter, 1000)
197
198 assert.deepStrictEqual(yield* series, [
199 [{ route: "/users" }, 1001],
200 [{ tenant: "a", route: "/users" }, 10],
201 [{ tenant: "b", route: "/users" }, 100]
202 ])
203 }).pipe(Effect.provideService(Metric.MetricRegistry, new Map())))
204
205 it.effect("applies inner attributes over outer, contextual and metric attributes", () =>
206 Effect.gen(function*() {
207 const counter = Metric.counter(nextId(), { attributes: { a: "own", b: "own", c: "own", d: "own" } }).pipe(
208 Metric.withAttributes({ a: "inner" }),
209 Metric.withAttributes({ a: "outer", b: "outer" })
210 )
211
212 yield* Metric.update(counter, 1).pipe(
213 Effect.provideService(Metric.CurrentMetricAttributes, { a: "context", b: "context", c: "context" })
214 )
215 yield* Metric.update(counter, 10)
216
217 assert.deepStrictEqual(yield* series, [
218 [{ a: "inner", b: "outer", c: "context", d: "own" }, 1],
219 [{ a: "inner", b: "outer", c: "own", d: "own" }, 10]
220 ])
221 }).pipe(Effect.provideService(Metric.MetricRegistry, new Map())))
222
223 it.effect("registers the series again after the registry is cleared", () => {
224 const registry: Metric.MetricRegistry = new Map()
225 return Effect.gen(function*() {
226 const counter = Metric.withAttributes(Metric.counter(nextId()), { route: "/users" })
227
228 yield* Metric.update(counter, 1)
229 registry.clear()
230 yield* Metric.update(counter, 10)
231
232 assert.deepStrictEqual(yield* series, [[{ route: "/users" }, 10]])
233 }).pipe(Effect.provideService(Metric.MetricRegistry, registry))
234 })
235
236 it.effect("uses the unattributed series for empty attributes", () =>
237 Effect.gen(function*() {
238 const counter = Metric.counter(nextId())
239
240 yield* Metric.update(Metric.withAttributes(counter, {}), 10)
241 yield* Metric.update(Metric.withAttributes(counter, []), 100)
242 yield* Metric.update(counter, 1)
243
244 assert.deepStrictEqual(yield* series, [[null, 111]])
245 }).pipe(Effect.provideService(Metric.MetricRegistry, new Map())))
246
247 it.effect("modifies and reads the attributed series", () =>
248 Effect.gen(function*() {
249 const gauge = Metric.gauge(nextId())
250 const tagged = Metric.withAttributes(gauge, [["route", "/users"]])
251
252 yield* Metric.update(tagged, 5)
253 yield* Metric.modify(tagged, 2)
254 yield* Metric.update(gauge, 1)
255
256 assert.strictEqual((yield* Metric.value(tagged)).value, 7)
257 assert.strictEqual((yield* Metric.value(gauge)).value, 1)
258 }).pipe(Effect.provideService(Metric.MetricRegistry, new Map())))
259 })
260
261 it.effect.each([
262 { name: "counter", makeUpdate: () => Metric.update(Metric.counter(nextId()), 1) },
263 {
264 name: "histogram",
265 makeUpdate: () => Metric.update(Metric.histogram(nextId(), { boundaries: [1] }), 1)
266 },
267 { name: "frequency", makeUpdate: () => Metric.update(Metric.frequency(nextId()), "value") }
268 ])("uses collection interval starts for delta $name points", ({ makeUpdate }) =>
269 Effect.gen(function*() {
270 const now = yield* Effect.acquireRelease(
271 Effect.sync(() => vi.spyOn(Date, "now").mockReturnValue(1000)),
272 (now) => Effect.sync(() => now.mockRestore())
273 )
274 const producer = yield* OtelMetrics.makeProducer("delta").pipe(Effect.provide(Resource.layerEmpty))
275 const update = makeUpdate()
276 const intervals = []
277
278 for (const time of [2000, 3000]) {
279 now.mockReturnValue(time)
280 yield* update
281 const result = yield* Effect.promise(() => producer.collect())
282 const point = result.resourceMetrics.scopeMetrics[0].metrics[0].dataPoints[0]
283 intervals.push([point.startTime, point.endTime])
284 }
285
286 assert.deepStrictEqual(intervals, [
287 [[1, 0], [2, 0]],
288 [[2, 0], [3, 0]]
289 ])
290 }).pipe(Effect.provideService(Metric.MetricRegistry, new Map())))
291
292 it.effect("should be referentially transparent", () =>
293 Effect.gen(function*() {
294 const id = nextId()
295 const counter1 = Metric.counter(id).pipe(
296 Metric.withAttributes(attributes),
297 Metric.withConstantInput(1)
298 )
299 const counter2 = Metric.counter(id).pipe(
300 Metric.withAttributes(attributes),
301 Metric.withConstantInput(1)
302 )
303 const counter3 = Metric.counter(id).pipe(
304 Metric.withAttributes({ z: "c" }),
305 Metric.withConstantInput(1)
306 )
307 yield* Effect.track(Effect.void, counter1)
308 yield* Effect.track(Effect.void, counter2)
309 yield* Effect.track(Effect.void, counter3)
310 const result1 = yield* Metric.value(counter1)
311 const result2 = yield* Metric.value(counter2)
312 const result3 = yield* Metric.value(counter3)
313 assert.deepStrictEqual(result1, { count: 2, incremental: false })
314 assert.deepStrictEqual(result2, { count: 2, incremental: false })
315 assert.deepStrictEqual(result3, { count: 1, incremental: false })
316 }))
317
318 it.effect("should dump the current state of all metrics", () =>
319 Effect.gen(function*() {
320 const counter1 = Metric.counter("counter").pipe(
321 Metric.withAttributes(attributes),
322 Metric.withConstantInput(1)
323 )
324 const counter2 = Metric.counter("counter").pipe(
325 Metric.withAttributes(attributes),
326 Metric.withConstantInput(1)
327 )
328 const counter3 = Metric.counter("counter").pipe(
329 Metric.withAttributes({ z: "c" }),
330 Metric.withConstantInput(1)
331 )
332
333 yield* Effect.track(Effect.void, counter1)
334 yield* Effect.track(Effect.void, counter2)
335 yield* Effect.track(Effect.void, counter3)
336
337 const result = yield* Metric.dump
338 const expected = String.stripMargin(
339 `|name=counter description= type=Counter attributes=[x: a, y: b] state=[count: [2]]
340 |name=counter description= type=Counter attributes=[z: c] state=[count: [1]]`
341 )
342
343 assert.strictEqual(result, expected)
344 }).pipe(Effect.provideService(Metric.MetricRegistry, new Map())))
345
346 it.effect("should record fiber runtime metrics once for yielded fibers", () => {
347 let starts = 0
348 let ends = 0
349 const service: Metric.FiberRuntimeMetricsService = {
350 recordFiberStart: () => {
351 starts++
352 },
353 recordFiberEnd: () => {
354 ends++
355 }
356 }
357
358 return Effect.gen(function*() {
359 const fiber = yield* Effect.forkChild(Effect.gen(function*() {
360 yield* Effect.yieldNow
361 yield* Effect.yieldNow
362 }))
363
364 yield* Fiber.join(fiber)
365
366 assert.strictEqual(starts, 1)
367 assert.strictEqual(ends, 1)
368 }).pipe(Effect.provideService(Metric.FiberRuntimeMetrics, service))
369 })
370
371 describe("Counter", () => {
372 it.effect("exports negative updates as deltas", () =>
373 Effect.gen(function*() {
374 type ExportRequest = {
375 readonly resourceMetrics: ReadonlyArray<{
376 readonly scopeMetrics: ReadonlyArray<{
377 readonly metrics: ReadonlyArray<{
378 readonly name: string
379 readonly sum?: {
380 readonly dataPoints: ReadonlyArray<{ readonly asDouble?: number }>
381 }
382 }>
383 }>
384 }>
385 }
386
387 const requests = yield* Ref.make<ReadonlyArray<ExportRequest>>([])
388 const client = HttpClient.makeWith(
389 Effect.fnUntraced(function*(requestEffect) {
390 const request = yield* requestEffect
391 if (request.body._tag === "Uint8Array") {
392 const body = JSON.parse(new TextDecoder().decode(request.body.body)) as ExportRequest
393 yield* Ref.update(requests, (requests) => [...requests, body])
394 }
395 return HttpClientResponse.fromWeb(request, new Response())
396 }),
397 Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
398 )
399 const layer = OtlpMetrics.layer({
400 url: "http://localhost:4318/v1/metrics",
401 resource: { serviceName: "test" },
402 temporality: "delta",
403 exportInterval: "1 hour"
404 }).pipe(
405 Layer.provide(OtlpSerialization.layerJson),
406 Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client))
407 )
408
409 const values = yield* Effect.gen(function*() {
410 const counter = Metric.counter("counter_delta")
411 const flusher = yield* OtlpExporter.Flusher
412 for (const update of [5, -2, 4]) {
413 yield* Metric.update(counter, update)
414 yield* flusher.flush
415 }
416 return (yield* Ref.get(requests)).map((request) =>
417 request.resourceMetrics[0].scopeMetrics[0].metrics.find((metric) => metric.name === "counter_delta")
418 ?.sum?.dataPoints[0].asDouble
419 )
420 }).pipe(
421 Effect.provide(layer),
422 Effect.provideService(Metric.MetricRegistry, new Map())
423 )
424
425 assert.deepStrictEqual(values, [5, -2, 4])
426 }))
427
428 it.effect("custom increment with value", () =>
429 Effect.gen(function*() {
430 const id = nextId()
431 const counter = Metric.counter(id).pipe(
432 Metric.withAttributes(attributes)
433 )
434 yield* Effect.trackSuccesses(Effect.succeed(1), counter)
435 yield* Effect.trackSuccesses(Effect.succeed(2), counter)
436 const result = yield* Metric.value(counter)
437 assert.deepStrictEqual(result, { count: 3, incremental: false })
438 }))
439
440 it.effect("custom increment with constant", () =>
441 Effect.gen(function*() {
442 const id = nextId()
443 const counter = Metric.counter(id).pipe(
444 Metric.withAttributes(attributes),
445 Metric.withConstantInput(1)
446 )
447 yield* Effect.trackSuccesses(Effect.succeed(1), counter)
448 yield* Effect.trackSuccesses(Effect.succeed(2), counter)
449 const result = yield* Metric.value(counter)
450 assert.deepStrictEqual(result, { count: 2, incremental: false })
451 }))
452
453 it.effect("custom decrement with value", () =>
454 Effect.gen(function*() {
455 const id = nextId()
456 const counter = Metric.counter(id).pipe(
457 Metric.withAttributes(attributes)
458 )
459 yield* Effect.trackSuccesses(Effect.succeed(-1), counter)
460 yield* Effect.trackSuccesses(Effect.succeed(-2), counter)
461 const result = yield* Metric.value(counter)
462 assert.deepStrictEqual(result, { count: -3, incremental: false })
463 }))
464
465 it.effect("custom decrement with constant", () =>
466 Effect.gen(function*() {
467 const id = nextId()
468 const counter = Metric.counter(id).pipe(
469 Metric.withAttributes(attributes),
470 Metric.withConstantInput(-1)
471 )
472 yield* Effect.trackSuccesses(Effect.succeed(-1), counter)
473 yield* Effect.trackSuccesses(Effect.succeed(-2), counter)
474 const result = yield* Metric.value(counter)
475 assert.deepStrictEqual(result, { count: -2, incremental: false })
476 }))
477
478 it.effect("custom increment with bigint value", () =>
479 Effect.gen(function*() {
480 const id = nextId()
481 const counter = Metric.counter(id, { bigint: true }).pipe(
482 Metric.withAttributes(attributes)
483 )
484 yield* Effect.trackSuccesses(Effect.succeed(BigInt(1)), counter)
485 yield* Effect.trackSuccesses(Effect.succeed(BigInt(2)), counter)
486 const result = yield* Metric.value(counter)
487 assert.deepStrictEqual(result, { count: BigInt(3), incremental: false })
488 }))
489
490 it.effect("custom increment with bigint constant", () =>
491 Effect.gen(function*() {
492 const id = nextId()
493 const counter = Metric.counter(id, { bigint: true }).pipe(
494 Metric.withAttributes(attributes),
495 Metric.withConstantInput(BigInt(1))
496 )
497 yield* Effect.trackSuccesses(Effect.succeed(BigInt(1)), counter)
498 yield* Effect.trackSuccesses(Effect.succeed(BigInt(2)), counter)
499 const result = yield* Metric.value(counter)
500 assert.deepStrictEqual(result, { count: BigInt(2), incremental: false })
501 }))
502
503 it.effect("custom decrement with bigint value", () =>
504 Effect.gen(function*() {
505 const id = nextId()
506 const counter = Metric.counter(id, { bigint: true }).pipe(
507 Metric.withAttributes(attributes)
508 )
509 yield* Effect.trackSuccesses(Effect.succeed(BigInt(-1)), counter)
510 yield* Effect.trackSuccesses(Effect.succeed(BigInt(-2)), counter)
511 const result = yield* Metric.value(counter)
512 assert.deepStrictEqual(result, { count: BigInt(-3), incremental: false })
513 }))
514
515 it.effect("custom decrement with bigint constant", () =>
516 Effect.gen(function*() {
517 const id = nextId()
518 const counter = Metric.counter(id, { bigint: true }).pipe(
519 Metric.withAttributes(attributes),
520 Metric.withConstantInput(BigInt(-1))
521 )
522 yield* Effect.trackSuccesses(Effect.succeed(BigInt(-1)), counter)
523 yield* Effect.trackSuccesses(Effect.succeed(BigInt(-2)), counter)
524 const result = yield* Metric.value(counter)
525 assert.deepStrictEqual(result, { count: BigInt(-2), incremental: false })
526 }))
527
528 it.effect("fails to decrement incremental counter", () =>
529 Effect.gen(function*() {
530 const id = nextId()
531 const counter = Metric.counter(id, { incremental: true }).pipe(
532 Metric.withAttributes(attributes),
533 Metric.withConstantInput(-1)
534 )
535 yield* Effect.trackSuccesses(Effect.succeed(-1), counter)
536 yield* Effect.trackSuccesses(Effect.succeed(-2), counter)
537 const result = yield* Metric.value(counter)
538 assert.deepStrictEqual(result, { count: 0, incremental: true })
539 }))
540
541 it.effect("fails to decrement incremental bigint counter", () =>
542 Effect.gen(function*() {
543 const id = nextId()
544 const counter = Metric.counter(id, { bigint: true, incremental: true }).pipe(
545 Metric.withAttributes(attributes),
546 Metric.withConstantInput(BigInt(-1))
547 )
548 yield* Effect.trackSuccesses(Effect.succeed(BigInt(-1)), counter)
549 yield* Effect.trackSuccesses(Effect.succeed(BigInt(-2)), counter)
550 const result = yield* Metric.value(counter)
551 assert.deepStrictEqual(result, { count: BigInt(0), incremental: true })
552 }))
553 })
554
555 describe("Gauge", () => {
556 it.effect("custom set with value", () =>
557 Effect.gen(function*() {
558 const id = nextId()
559 const gauge = Metric.gauge(id).pipe(
560 Metric.withAttributes(attributes)
561 )
562 yield* Effect.trackSuccesses(Effect.succeed(1), gauge)
563 yield* Effect.trackSuccesses(Effect.succeed(2), gauge)
564 const result = yield* Metric.value(gauge)
565 assert.deepStrictEqual(result, { value: 2 })
566 }))
567
568 it.effect("custom set with constant", () =>
569 Effect.gen(function*() {
570 const id = nextId()
571 const gauge = Metric.gauge(id).pipe(
572 Metric.withAttributes(attributes),
573 Metric.withConstantInput(1)
574 )
575 yield* Effect.trackSuccesses(Effect.succeed(1), gauge)
576 yield* Effect.trackSuccesses(Effect.succeed(2), gauge)
577 const result = yield* Metric.value(gauge)
578 assert.deepStrictEqual(result, { value: 1 })
579 }))
580
581 it.effect("custom set with negative value", () =>
582 Effect.gen(function*() {
583 const id = nextId()
584 const gauge = Metric.gauge(id).pipe(
585 Metric.withAttributes(attributes)
586 )
587 yield* Effect.trackSuccesses(Effect.succeed(-1), gauge)
588 yield* Effect.trackSuccesses(Effect.succeed(-2), gauge)
589 const result = yield* Metric.value(gauge)
590 assert.deepStrictEqual(result, { value: -2 })
591 }))
592
593 it.effect("custom set with negative constant", () =>
594 Effect.gen(function*() {
595 const id = nextId()
596 const gauge = Metric.gauge(id).pipe(
597 Metric.withAttributes(attributes),
598 Metric.withConstantInput(-1)
599 )
600 yield* Effect.trackSuccesses(Effect.succeed(-1), gauge)
601 yield* Effect.trackSuccesses(Effect.succeed(-2), gauge)
602 const result = yield* Metric.value(gauge)
603 assert.deepStrictEqual(result, { value: -1 })
604 }))
605
606 it.effect("custom set with bigint value", () =>
607 Effect.gen(function*() {
608 const id = nextId()
609 const gauge = Metric.gauge(id, { bigint: true }).pipe(
610 Metric.withAttributes(attributes)
611 )
612 yield* Effect.trackSuccesses(Effect.succeed(BigInt(1)), gauge)
613 yield* Effect.trackSuccesses(Effect.succeed(BigInt(2)), gauge)
614 const result = yield* Metric.value(gauge)
615 assert.deepStrictEqual(result, { value: BigInt(2) })
616 }))
617
618 it.effect("custom set with bigint constant", () =>
619 Effect.gen(function*() {
620 const id = nextId()
621 const gauge = Metric.gauge(id, { bigint: true }).pipe(
622 Metric.withAttributes(attributes),
623 Metric.withConstantInput(BigInt(1))
624 )
625 yield* Effect.trackSuccesses(Effect.succeed(BigInt(1)), gauge)
626 yield* Effect.trackSuccesses(Effect.succeed(BigInt(2)), gauge)
627 const result = yield* Metric.value(gauge)
628 assert.deepStrictEqual(result, { value: BigInt(1) })
629 }))
630
631 it.effect("custom set with negative bigint value", () =>
632 Effect.gen(function*() {
633 const id = nextId()
634 const gauge = Metric.gauge(id, { bigint: true }).pipe(
635 Metric.withAttributes(attributes)
636 )
637 yield* Effect.trackSuccesses(Effect.succeed(BigInt(-1)), gauge)
638 yield* Effect.trackSuccesses(Effect.succeed(BigInt(-2)), gauge)
639 const result = yield* Metric.value(gauge)
640 assert.deepStrictEqual(result, { value: BigInt(-2) })
641 }))
642
643 it.effect("custom set with negative bigint constant", () =>
644 Effect.gen(function*() {
645 const id = nextId()
646 const gauge = Metric.gauge(id, { bigint: true }).pipe(
647 Metric.withAttributes(attributes),
648 Metric.withConstantInput(BigInt(-1))
649 )
650 yield* Effect.trackSuccesses(Effect.succeed(BigInt(-1)), gauge)
651 yield* Effect.trackSuccesses(Effect.succeed(BigInt(-2)), gauge)
652 const result = yield* Metric.value(gauge)
653 assert.deepStrictEqual(result, { value: BigInt(-1) })
654 }))
655 })
656
657 describe("Frequency", () => {
658 it.effect("custom occurence with value", () =>
659 Effect.gen(function*() {
660 const id = nextId()
661 const frequency = Metric.frequency(id).pipe(
662 Metric.withAttributes(attributes)
663 )
664 yield* Effect.trackSuccesses(Effect.succeed("foo"), frequency)
665 yield* Effect.trackSuccesses(Effect.succeed("bar"), frequency)
666 const result = yield* Metric.value(frequency)
667 assert.deepStrictEqual(result, {
668 occurrences: new Map([["foo", 1], ["bar", 1]])
669 })
670 }))
671
672 it.effect("custom set with constant", () =>
673 Effect.gen(function*() {
674 const id = nextId()
675 const frequency = Metric.frequency(id).pipe(
676 Metric.withAttributes(attributes),
677 Metric.withConstantInput("constant")
678 )
679 yield* Effect.trackSuccesses(Effect.succeed("foo"), frequency)
680 yield* Effect.trackSuccesses(Effect.succeed("bar"), frequency)
681 const result = yield* Metric.value(frequency)
682 assert.deepStrictEqual(result, {
683 occurrences: new Map([["constant", 2]])
684 })
685 }))
686 })
687
688 it.effect("uses finite extrema for empty histogram and summary states", () =>
689 Effect.gen(function*() {
690 const histogram = Metric.histogram(nextId(), { boundaries: [] })
691 const summary = Metric.summary(nextId(), {
692 maxAge: "1 minute",
693 maxSize: 10,
694 quantiles: []
695 })
696 const histogramState = yield* Metric.value(histogram)
697 const summaryState = yield* Metric.value(summary)
698 assert.deepStrictEqual(
699 { min: histogramState.min, max: histogramState.max },
700 { min: Number.MAX_VALUE, max: -Number.MAX_VALUE }
701 )
702 assert.deepStrictEqual(
703 { min: summaryState.min, max: summaryState.max },
704 { min: Number.MAX_VALUE, max: -Number.MAX_VALUE }
705 )
706 }))
707
708 it("creates evenly spaced linear boundaries", () => {
709 assert.deepStrictEqual(
710 Metric.linearBoundaries({ start: 10, width: 20, count: 5 }),
711 [10, 30, 50, 70, Number.POSITIVE_INFINITY]
712 )
713 })
714
715 describe("Histogram", () => {
716 it.effect("reports the maximum for negative-only observations", () =>
717 Effect.gen(function*() {
718 const histogram = Metric.histogram(nextId(), { boundaries: [-10, -5, 0] })
719 yield* Metric.update(histogram, -10)
720 yield* Metric.update(histogram, -5)
721 const result = yield* Metric.value(histogram)
722 assert.strictEqual(result.min, -10)
723 assert.strictEqual(result.max, -5)
724 }))
725
726 it.effect("custom observe with value", () =>
727 Effect.gen(function*() {
728 const id = nextId()
729 const boundaries = Metric.linearBoundaries({ start: 0, width: 1, count: 10 })
730 const histogram = Metric.histogram(id, { boundaries }).pipe(
731 Metric.withAttributes(attributes)
732 )
733 yield* Effect.trackSuccesses(Effect.succeed(1), histogram)
734 yield* Effect.trackSuccesses(Effect.succeed(3), histogram)
735 const result = yield* Metric.value(histogram)
736 assert.deepStrictEqual(result, {
737 buckets: makeBuckets(boundaries, [1, 3]),
738 count: 2,
739 sum: 4,
740 min: 1,
741 max: 3
742 })
743 }))
744
745 it.effect("custom observe with constant", () =>
746 Effect.gen(function*() {
747 const id = nextId()
748 const boundaries = Metric.linearBoundaries({ start: 0, width: 1, count: 10 })
749 const histogram = Metric.histogram(id, { boundaries }).pipe(
750 Metric.withAttributes(attributes),
751 Metric.withConstantInput(1)
752 )
753 yield* Effect.trackSuccesses(Effect.succeed(1), histogram)
754 yield* Effect.trackSuccesses(Effect.succeed(3), histogram)
755 const result = yield* Metric.value(histogram)
756 assert.deepStrictEqual(result, {
757 buckets: makeBuckets(boundaries, [1, 1]),
758 count: 2,
759 sum: 2,
760 min: 1,
761 max: 1
762 })
763 }))
764
765 it.effect("preserves precision of boundary values", () =>
766 Effect.gen(function*() {
767 const boundaries = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1]
768
769 const histogram = Metric.histogram("precision_test", { boundaries })
770
771 const result = yield* Metric.value(histogram)
772
773 result.buckets.forEach(([boundary], index) => {
774 if (index < boundaries.length) {
775 assert.strictEqual(boundary, boundaries[index])
776 }
777 })
778 }))
779 })
780
781 describe("Summary", () => {
782 it.effect("reports the maximum for negative-only observations", () =>
783 Effect.gen(function*() {
784 const summary = Metric.summary(nextId(), {
785 maxAge: "1 minute",
786 maxSize: 10,
787 quantiles: [0.5]
788 })
789 yield* Metric.update(summary, -10)
790 yield* Metric.update(summary, -5)
791 const result = yield* Metric.value(summary)
792 assert.strictEqual(result.min, -10)
793 assert.strictEqual(result.max, -5)
794 }))
795
796 it.effect("custom observe with value", () =>
797 Effect.gen(function*() {
798 const id = nextId()
799 const quantiles = [0, 0.1, .9]
800 const summary = Metric.summary(id, {
801 maxAge: "1 minute",
802 maxSize: 10,
803 quantiles
804 }).pipe(Metric.withAttributes(attributes))
805 yield* Effect.trackSuccesses(Effect.succeed(1), summary)
806 yield* Effect.trackSuccesses(Effect.succeed(3), summary)
807 const result = yield* Metric.value(summary)
808 assert.deepStrictEqual(result, {
809 quantiles: [[0, 1], [0.1, 1], [0.9, 3]],
810 count: 2,
811 sum: 4,
812 min: 1,
813 max: 3
814 })
815 }))
816
817 it.effect("custom observe with constant", () =>
818 Effect.gen(function*() {
819 const id = nextId()
820 const quantiles = [0, 0.1, .9]
821 const summary = Metric.summary(id, {
822 maxAge: "1 minute",
823 maxSize: 10,
824 quantiles
825 }).pipe(
826 Metric.withAttributes(attributes),
827 Metric.withConstantInput(1)
828 )
829 yield* Effect.trackSuccesses(Effect.succeed(1), summary)
830 yield* Effect.trackSuccesses(Effect.succeed(3), summary)
831 const result = yield* Metric.value(summary)
832 assert.deepStrictEqual(result, {
833 quantiles: [[0, 1], [0.1, 1], [0.9, 1]],
834 count: 2,
835 sum: 2,
836 min: 1,
837 max: 1
838 })
839 }))
840
841 it.effect("should return the correct quantile when the first chunk overshoots", () =>
842 Effect.gen(function*() {
843 const id = nextId()
844 const samples = [10, 10, 10, 10, 10, 10, 20, 30, 40, 50]
845 const summary = Metric.summary(id, {
846 maxAge: "1 minute",
847 maxSize: 15,
848 quantiles: [0.5]
849 })
850 yield* Effect.forEach(samples, (sample) => Metric.update(summary, sample))
851 const result = yield* Metric.value(summary)
852 assert.deepStrictEqual(result, {
853 quantiles: [[0.5, 10]],
854 count: 10,
855 min: 10,
856 max: 50,
857 sum: 200
858 })
859 }))
860 })
861
862 describe("track", () => {
863 it.effect("updates on success", () =>
864 Effect.gen(function*() {
865 const id = nextId()
866 const counter = Metric.counter(id).pipe(Metric.withConstantInput(1))
867 yield* Effect.succeed(1).pipe(
868 Effect.track(counter)
869 )
870 const result = yield* Metric.value(counter)
871 assert.deepStrictEqual(result, { count: 1, incremental: false })
872 }))
873
874 it.effect("updates on failure", () =>
875 Effect.gen(function*() {
876 const id = nextId()
877 const counter = Metric.counter(id).pipe(Metric.withConstantInput(1))
878 yield* Effect.fail(1).pipe(
879 Effect.track(counter),
880 Effect.exit
881 )
882 const result = yield* Metric.value(counter)
883 assert.deepStrictEqual(result, { count: 1, incremental: false })
884 }))
885
886 it.effect("updates on defect", () =>
887 Effect.gen(function*() {
888 const id = nextId()
889 const counter = Metric.counter(id).pipe(Metric.withConstantInput(1))
890 yield* Effect.die(1).pipe(
891 Effect.track(counter),
892 Effect.exit
893 )
894 const result = yield* Metric.value(counter)
895 assert.deepStrictEqual(result, { count: 1, incremental: false })
896 }))
897 })
898
899 describe("track", () => {
900 it.effect("maps exits while preserving the instrumented effect result", () =>
901 Effect.gen(function*() {
902 const id = nextId()
903 const counter = Metric.counter(id).pipe(
904 Metric.withConstantInput(1)
905 )
906 yield* Effect.succeed(1).pipe(
907 Effect.track(counter)
908 )
909 const result = yield* Metric.value(counter)
910 assert.deepStrictEqual(result, { count: 1, incremental: false })
911 }))
912
913 it.effect("updates on failure", () =>
914 Effect.gen(function*() {
915 const id = nextId()
916 const counter = Metric.counter(id).pipe(
917 Metric.withConstantInput(1)
918 )
919 yield* Effect.fail(1).pipe(
920 Effect.track(counter),
921 Effect.exit
922 )
923 const result = yield* Metric.value(counter)
924 assert.deepStrictEqual(result, { count: 1, incremental: false })
925 }))
926
927 it.effect("updates on defect", () =>
928 Effect.gen(function*() {
929 const id = nextId()
930 const counter = Metric.counter(id).pipe(
931 Metric.withConstantInput(1)
932 )
933 yield* Effect.die(1).pipe(
934 Effect.track(counter),
935 Effect.exit
936 )
937 const result = yield* Metric.value(counter)
938 assert.deepStrictEqual(result, { count: 1, incremental: false })
939 }))
940 })
941
942 describe("trackErrors", () => {
943 it.effect("does not update on success", () =>
944 Effect.gen(function*() {
945 const id = nextId()
946 const counter = Metric.counter(id).pipe(Metric.withConstantInput(1))
947 yield* Effect.succeed(1).pipe(
948 Effect.trackErrors(counter)
949 )
950 const result = yield* Metric.value(counter)
951 assert.deepStrictEqual(result, { count: 0, incremental: false })
952 }))
953
954 it.effect("updates on failure", () =>
955 Effect.gen(function*() {
956 const id = nextId()
957 const counter = Metric.counter(id).pipe(Metric.withConstantInput(1))
958 yield* Effect.fail(1).pipe(
959 Effect.trackErrors(counter),
960 Effect.exit
961 )
962 const result = yield* Metric.value(counter)
963 assert.deepStrictEqual(result, { count: 1, incremental: false })
964 }))
965
966 it.effect("does not update on defect", () =>
967 Effect.gen(function*() {
968 const id = nextId()
969 const counter = Metric.counter(id).pipe(Metric.withConstantInput(1))
970 yield* Effect.die(1).pipe(
971 Effect.trackErrors(counter),
972 Effect.exit
973 )
974 const result = yield* Metric.value(counter)
975 assert.deepStrictEqual(result, { count: 0, incremental: false })
976 }))
977 })
978
979 describe("trackDefects", () => {
980 it.effect("does not update on success", () =>
981 Effect.gen(function*() {
982 const id = nextId()
983 const counter = Metric.counter(id).pipe(Metric.withConstantInput(1))
984 yield* Effect.succeed(1).pipe(
985 Effect.trackDefects(counter)
986 )
987 const result = yield* Metric.value(counter)
988 assert.deepStrictEqual(result, { count: 0, incremental: false })
989 }))
990
991 it.effect("does not update on failure", () =>
992 Effect.gen(function*() {
993 const id = nextId()
994 const counter = Metric.counter(id).pipe(Metric.withConstantInput(1))
995 yield* Effect.fail(1).pipe(
996 Effect.trackDefects(counter),
997 Effect.exit
998 )
999 const result = yield* Metric.value(counter)
1000 assert.deepStrictEqual(result, { count: 0, incremental: false })
1001 }))
1002
1003 it.effect("updates on defect", () =>
1004 Effect.gen(function*() {
1005 const id = nextId()
1006 const counter = Metric.counter(id).pipe(Metric.withConstantInput(1))
1007 yield* Effect.die(1).pipe(
1008 Effect.trackDefects(counter),
1009 Effect.exit
1010 )
1011 const result = yield* Metric.value(counter)
1012 assert.deepStrictEqual(result, { count: 1, incremental: false })
1013 }))
1014 })
1015
1016 describe("trackDuration", () => {
1017 it.effect("tracks execution duration", () =>
1018 Effect.gen(function*() {
1019 const id = nextId()
1020 const timer = Metric.timer(id)
1021 const fiber = yield* Effect.sleep("1 hour").pipe(
1022 Effect.trackDuration(timer),
1023 Effect.forkChild
1024 )
1025 yield* TestClock.adjust("1 hour")
1026 yield* Fiber.join(fiber)
1027 const result = yield* Metric.value(timer)
1028 assert.strictEqual(result.count, 1)
1029 assert.strictEqual(result.min, Duration.toMillis(Duration.hours(1)))
1030 assert.strictEqual(result.max, Duration.toMillis(Duration.hours(1)))
1031 assert.strictEqual(result.sum, Duration.toMillis(Duration.hours(1)))
1032 }))
1033
1034 it.effect("uses monotonic time when wall time moves backward", () =>
1035 Effect.gen(function*() {
1036 const id = nextId()
1037 const timer = Metric.timer(id)
1038 yield* TestClock.setTime(1_000)
1039 yield* Effect.gen(function*() {
1040 yield* TestClock.adjust("100 millis")
1041 yield* TestClock.setTime(0)
1042 }).pipe(Effect.trackDuration(timer))
1043
1044 const result = yield* Metric.value(timer)
1045 assert.strictEqual(result.count, 1)
1046 assert.strictEqual(result.min, 100)
1047 assert.strictEqual(result.max, 100)
1048 assert.strictEqual(result.sum, 100)
1049 }))
1050 })
1051
1052 describe("trackDurationWith", () => {
1053 it.effect("tracks execution duration", () =>
1054 Effect.gen(function*() {
1055 const id = nextId()
1056 const gauge = Metric.gauge(id)
1057 const fiber = yield* Effect.sleep("1 hour").pipe(
1058 Effect.trackDuration(gauge, (duration) => Duration.toMinutes(duration)),
1059 Effect.forkChild
1060 )
1061 yield* TestClock.adjust("1 hour")
1062 yield* Fiber.join(fiber)
1063 const result = yield* Metric.value(gauge)
1064 assert.deepStrictEqual(result, { value: 60 })
1065 }))
1066 })
1067})
1068
1069let idCounter = 0
1070function nextId() {
1071 return `metric-${++idCounter}`
1072}
1073
1074const makeBuckets = (
1075 boundaries: ReadonlyArray<number>,
1076 values: ReadonlyArray<number>
1077): ReadonlyArray<[number, number]> => {
1078 const results: Array<[number, number]> = []
1079 let count = 0
1080 let index = 0
1081 for (const bucket of boundaries) {
1082 while (index < values.length && values[index] <= bucket) {
1083 count++
1084 index++
1085 }
1086 results.push([bucket, count])
1087 }
1088 return results
1089}