1import { assert, describe, it } from "@effect/vitest"
2import { Array, Context, Deferred, Effect, Fiber, Layer, Metric, Predicate, Ref } from "effect"
3import { HttpClient, type HttpClientError, HttpClientResponse } from "effect/http"
4import { OtlpExporter, OtlpMetrics, OtlpSerialization } from "effect/observability"
5import { TestClock } from "effect/testing"
6
7describe("OtlpMetrics", () => {
8 it.effect("retains delta checkpoints after a failed export", () =>
9 Effect.gen(function*() {
10 const bodies = yield* Ref.make<ReadonlyArray<OtlpExportRequest>>([])
11 const attempts = yield* Ref.make(0)
12 const client = HttpClient.makeWith(
13 Effect.fnUntraced(function*(requestEffect) {
14 const request = yield* requestEffect
15 if (request.body._tag === "Uint8Array") {
16 const body = JSON.parse(new TextDecoder().decode(request.body.body)) as OtlpExportRequest
17 yield* Ref.update(bodies, Array.append(body))
18 }
19 const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1)
20 return HttpClientResponse.fromWeb(request, new Response(null, { status: attempt === 1 ? 400 : 200 }))
21 }),
22 Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
23 )
24 const layer = OtlpMetrics.layer({
25 url: "http://localhost:4318/v1/metrics",
26 resource: { serviceName: "repro" },
27 temporality: "delta",
28 exportInterval: "1 hour"
29 }).pipe(
30 Layer.provide(OtlpSerialization.layerJson),
31 Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client))
32 )
33 yield* Effect.gen(function*() {
34 yield* Metric.update(Metric.counter("repro_counter"), 5)
35 const histogram = Metric.histogram("repro_histogram", { boundaries: [10, 50, 100] })
36 yield* Metric.update(histogram, 25)
37 yield* Metric.update(histogram, 75)
38 const frequency = Metric.frequency("repro_frequency")
39 yield* Metric.update(frequency, "a")
40 yield* Metric.update(frequency, "a")
41 yield* Metric.update(frequency, "b")
42 const summary = Metric.summary("repro_summary", {
43 maxAge: "1 minute",
44 maxSize: 100,
45 quantiles: [0.5]
46 })
47 yield* Metric.update(summary, 10)
48 yield* Metric.update(summary, 20)
49 yield* Metric.update(summary, 30)
50 const flusher = yield* OtlpExporter.Flusher
51 yield* flusher.flush
52 yield* TestClock.adjust("60 seconds")
53 yield* flusher.flush
54 }).pipe(Effect.provide(layer), Effect.provideService(Metric.MetricRegistry, new Map()))
55 const [first, second] = yield* Ref.get(bodies)
56 assert.strictEqual(findMetric(first, "repro_counter")?.sum?.dataPoints[0].asDouble, 5)
57 assert.strictEqual(findMetric(second, "repro_counter")?.sum?.dataPoints[0].asDouble, 5)
58 assert.strictEqual(findMetric(first, "repro_histogram")?.histogram?.dataPoints[0].count, 2)
59 assert.strictEqual(findMetric(second, "repro_histogram")?.histogram?.dataPoints[0].count, 2)
60 assert.strictEqual(findMetric(first, "repro_histogram")?.histogram?.dataPoints[0].sum, 100)
61 assert.strictEqual(findMetric(second, "repro_histogram")?.histogram?.dataPoints[0].sum, 100)
62 assert.strictEqual(findFrequencyValue(first, "repro_frequency", "a"), 2)
63 assert.strictEqual(findFrequencyValue(second, "repro_frequency", "a"), 2)
64 assert.strictEqual(findFrequencyValue(first, "repro_frequency", "b"), 1)
65 assert.strictEqual(findFrequencyValue(second, "repro_frequency", "b"), 1)
66 assert.strictEqual(findMetric(first, "repro_summary_count")?.sum?.dataPoints[0].asInt, 3)
67 assert.strictEqual(findMetric(second, "repro_summary_count")?.sum?.dataPoints[0].asInt, 3)
68 assert.strictEqual(findMetric(first, "repro_summary_sum")?.sum?.dataPoints[0].asDouble, 60)
69 assert.strictEqual(findMetric(second, "repro_summary_sum")?.sum?.dataPoints[0].asDouble, 60)
70 assert.strictEqual(
71 findMetric(second, "repro_counter")?.sum?.dataPoints[0].startTimeUnixNano,
72 findMetric(first, "repro_counter")?.sum?.dataPoints[0].startTimeUnixNano
73 )
74 }))
75
76 it.effect("does not regress delta checkpoints when exports complete out of order", () =>
77 Effect.scoped(Effect.gen(function*() {
78 const bodies = yield* Ref.make<ReadonlyArray<OtlpExportRequest>>([])
79 const started = yield* Effect.forEach([0, 1], () => Deferred.make<void>())
80 const releases = yield* Effect.forEach([0, 1], () => Deferred.make<void>())
81 let requestIndex = 0
82 const client = HttpClient.makeWith(
83 Effect.fnUntraced(function*(requestEffect) {
84 const request = yield* requestEffect
85 if (request.body._tag === "Uint8Array") {
86 const body = JSON.parse(new TextDecoder().decode(request.body.body)) as OtlpExportRequest
87 yield* Ref.update(bodies, Array.append(body))
88 }
89 const index = requestIndex++
90 if (index < 2) {
91 yield* Deferred.succeed(started[index], undefined)
92 yield* Deferred.await(releases[index])
93 }
94 return HttpClientResponse.fromWeb(request, new Response())
95 }),
96 Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
97 )
98 const layer = OtlpMetrics.layer({
99 url: "http://localhost:4318/v1/metrics",
100 resource: { serviceName: "repro" },
101 temporality: "delta",
102 exportInterval: "1 hour"
103 }).pipe(
104 Layer.provide(OtlpSerialization.layerJson),
105 Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client))
106 )
107 yield* Effect.gen(function*() {
108 const counter = Metric.counter("concurrent_delta")
109 const flusher = yield* OtlpExporter.Flusher
110 yield* Metric.update(counter, 1)
111 const first = yield* Effect.forkChild(flusher.flush)
112 yield* Deferred.await(started[0])
113
114 yield* TestClock.adjust("1 second")
115 yield* Metric.update(counter, 1)
116 const second = yield* Effect.forkChild(flusher.flush)
117 yield* Deferred.await(started[1])
118
119 yield* Deferred.succeed(releases[1], undefined)
120 yield* Fiber.join(second)
121 yield* Deferred.succeed(releases[0], undefined)
122 yield* Fiber.join(first)
123
124 yield* TestClock.adjust("1 second")
125 yield* flusher.flush
126 }).pipe(Effect.provide(layer), Effect.provideService(Metric.MetricRegistry, new Map()))
127
128 const requests = yield* Ref.get(bodies)
129 assert.strictEqual(findMetric(requests[2], "concurrent_delta")?.sum?.dataPoints[0].asDouble, 0)
130 })))
131
132 describe("cumulative temporality", () => {
133 it.effect("reports counter totals across export intervals", () =>
134 Effect.gen(function*() {
135 const metricName = "cumulative_counter_test"
136 const counter = Metric.counter(metricName, {
137 description: "Test counter"
138 })
139
140 // First interval: increment by 5
141 yield* Metric.update(counter, 5)
142 yield* triggerExport
143
144 // Second interval: increment by 3 more (total: 8)
145 yield* Metric.update(counter, 3)
146 yield* triggerExport
147
148 const requests = yield* MockHttpClient.requests
149 assert.isAtLeast(requests.length, 2)
150
151 const firstMetric = findMetric(requests[0], metricName)
152 assert.strictEqual(firstMetric?.sum?.dataPoints[0].asDouble, 5)
153
154 // Second export should report cumulative 8
155 const secondMetric = findMetric(requests[1], metricName)
156 assert.strictEqual(secondMetric?.sum?.dataPoints[0].asDouble, 8)
157 }).pipe(Effect.provide(TestLayerCumulative)))
158
159 it.effect("reports histogram count and sum across export intervals", () =>
160 Effect.gen(function*() {
161 const metricName = "cumulative_histogram_test"
162
163 const histogram = Metric.histogram(metricName, {
164 description: "Test histogram",
165 boundaries: [10, 50, 100]
166 })
167
168 // First interval: observe values 25, 75 (count=2, sum=100)
169 yield* Metric.update(histogram, 25)
170 yield* Metric.update(histogram, 75)
171 yield* triggerExport
172
173 // Second interval: observe value 30 (cumulative count=3, sum=130)
174 yield* Metric.update(histogram, 30)
175 yield* triggerExport
176
177 const requests = yield* MockHttpClient.requests
178 assert.isAtLeast(requests.length, 2)
179
180 const firstMetric = findMetric(requests[0], metricName)
181 assert.strictEqual(firstMetric?.histogram?.dataPoints[0].count, 2)
182 assert.strictEqual(firstMetric?.histogram?.dataPoints[0].sum, 100)
183
184 const secondMetric = findMetric(requests[1], metricName)
185 assert.isDefined(secondMetric)
186 assert.strictEqual(secondMetric?.histogram?.dataPoints[0].count, 3)
187 assert.strictEqual(secondMetric?.histogram?.dataPoints[0].sum, 130)
188 }).pipe(Effect.provide(TestLayerCumulative)))
189
190 it.effect("reports frequency counts across export intervals", () =>
191 Effect.gen(function*() {
192 const metricName = "cumulative_frequency_test"
193 const frequency = Metric.frequency(metricName, {
194 description: "Test frequency"
195 })
196
197 // First export interval: "a" x 2, "b" x 1
198 yield* Metric.update(frequency, "a")
199 yield* Metric.update(frequency, "a")
200 yield* Metric.update(frequency, "b")
201 yield* triggerExport
202
203 // Second export interval: "a" x 1, "b" x 2 more (cumulative: "a"=3, "b"=3)
204 yield* Metric.update(frequency, "a")
205 yield* Metric.update(frequency, "b")
206 yield* Metric.update(frequency, "b")
207 yield* triggerExport
208
209 const requests = yield* MockHttpClient.requests
210 assert.isAtLeast(requests.length, 2)
211
212 const firstMetric = findMetric(requests[0], metricName)
213 const firstDataPoints = firstMetric?.sum?.dataPoints
214 const firstA = firstDataPoints?.find((dp) =>
215 dp.attributes.some((attr) =>
216 attr.key === "key" &&
217 Predicate.hasProperty(attr.value, "stringValue") &&
218 attr.value.stringValue === "a"
219 )
220 )
221 const firstB = firstDataPoints?.find((dp) =>
222 dp.attributes.some((attr) =>
223 attr.key === "key" &&
224 Predicate.hasProperty(attr.value, "stringValue") &&
225 attr.value.stringValue === "b"
226 )
227 )
228 assert.strictEqual(firstA?.asInt, 2)
229 assert.strictEqual(firstB?.asInt, 1)
230
231 // Second export should report cumulative values
232 const secondMetric = findMetric(requests[1], metricName)
233 assert.isDefined(secondMetric)
234 const secondDataPoints = secondMetric?.sum?.dataPoints
235 const secondA = secondDataPoints?.find((dp) =>
236 dp.attributes.some((attr) =>
237 attr.key === "key" &&
238 Predicate.hasProperty(attr.value, "stringValue") &&
239 attr.value.stringValue === "a"
240 )
241 )
242 const secondB = secondDataPoints?.find((dp) =>
243 dp.attributes.some((attr) =>
244 attr.key === "key" &&
245 Predicate.hasProperty(attr.value, "stringValue") &&
246 attr.value.stringValue === "b"
247 )
248 )
249 assert.strictEqual(secondA?.asInt, 3) // Cumulative: 2+1=3
250 assert.strictEqual(secondB?.asInt, 3) // Cumulative: 1+2=3
251 }).pipe(Effect.provide(TestLayerCumulative)))
252
253 it.effect("reports summary count and sum across export intervals", () =>
254 Effect.gen(function*() {
255 const metricName = "cumulative_summary_test"
256 const summary = Metric.summary(metricName, {
257 description: "Test summary",
258 maxAge: "1 minute",
259 maxSize: 100,
260 quantiles: [0.5, 0.9, 0.99]
261 })
262
263 // First interval: observe 10, 20, 30 (count=3, sum=60)
264 yield* Metric.update(summary, 10)
265 yield* Metric.update(summary, 20)
266 yield* Metric.update(summary, 30)
267 yield* triggerExport
268
269 // Second interval: observe 40 (cumulative count=4, sum=100)
270 yield* Metric.update(summary, 40)
271 yield* triggerExport
272
273 const requests = yield* MockHttpClient.requests
274 assert.isAtLeast(requests.length, 2)
275
276 // First export - check count and sum metrics
277 const firstCount = findMetric(requests[0], `${metricName}_count`)
278 assert.strictEqual(firstCount?.sum?.dataPoints[0].asInt, 3)
279
280 const firstSum = findMetric(requests[0], `${metricName}_sum`)
281 assert.strictEqual(firstSum?.sum?.dataPoints[0].asDouble, 60)
282
283 // Second export should report cumulative values
284 const secondCount = findMetric(requests[1], `${metricName}_count`)
285 assert.strictEqual(secondCount?.sum?.dataPoints[0].asInt, 4)
286
287 const secondSum = findMetric(requests[1], `${metricName}_sum`)
288 assert.strictEqual(secondSum?.sum?.dataPoints[0].asDouble, 100)
289 }).pipe(Effect.provide(TestLayerCumulative)))
290 })
291
292 describe("delta temporality", () => {
293 it.effect("reports counter deltas across export intervals", () =>
294 Effect.gen(function*() {
295 const metricName = "delta_counter_test"
296 const counter = Metric.counter(metricName, {
297 description: "Test counter"
298 })
299
300 // First export interval - increment the counter by 5
301 yield* Metric.update(counter, 5)
302 yield* triggerExport
303
304 // Second export interval - increment the counter by 3 more (total: 8)
305 yield* Metric.update(counter, 3)
306 yield* triggerExport
307
308 const requests = yield* MockHttpClient.requests
309 assert.isAtLeast(requests.length, 2)
310
311 const firstMetric = findMetric(requests[0], metricName)
312 assert.strictEqual(firstMetric?.sum?.dataPoints[0].asDouble, 5)
313
314 const secondMetric = findMetric(requests[1], metricName)
315 assert.strictEqual(secondMetric?.sum?.dataPoints[0].asDouble, 3)
316 }).pipe(Effect.provide(TestLayerDelta)))
317
318 it.effect("reports histogram count and sum deltas across export intervals", () =>
319 Effect.gen(function*() {
320 const metricName = "delta_histogram_test"
321 const histogram = Metric.histogram(metricName, {
322 description: "Test histogram",
323 boundaries: [10, 50, 100]
324 })
325
326 // First export interval: observe values 25, 75 (count=2, sum=100)
327 yield* Metric.update(histogram, 25)
328 yield* Metric.update(histogram, 75)
329 yield* triggerExport
330
331 // Second export interval: observe value 30 (delta count=1, delta sum=30)
332 yield* Metric.update(histogram, 30)
333 yield* triggerExport
334
335 const requests = yield* MockHttpClient.requests
336 assert.isAtLeast(requests.length, 2)
337
338 const firstMetric = findMetric(requests[0], metricName)
339 assert.strictEqual(firstMetric?.histogram?.dataPoints[0].count, 2)
340 assert.strictEqual(firstMetric!.histogram!.dataPoints[0].sum, 100)
341
342 const secondMetric = findMetric(requests[1], metricName)
343 assert.strictEqual(secondMetric?.histogram?.dataPoints[0].count, 1)
344 assert.strictEqual(secondMetric?.histogram?.dataPoints[0].sum, 30)
345 }).pipe(Effect.provide(TestLayerDelta)))
346
347 it.effect("reports frequency count deltas across export intervals", () =>
348 Effect.gen(function*() {
349 const metricName = "delta_frequency_test"
350 const frequency = Metric.frequency(metricName, {
351 description: "Test frequency"
352 })
353
354 // First export interval: "a" x 2, "b" x 1
355 yield* Metric.update(frequency, "a")
356 yield* Metric.update(frequency, "a")
357 yield* Metric.update(frequency, "b")
358 yield* triggerExport
359
360 // Second export interval: "a" x 1, "b" x 2 more
361 yield* Metric.update(frequency, "a")
362 yield* Metric.update(frequency, "b")
363 yield* Metric.update(frequency, "b")
364 yield* triggerExport
365
366 const requests = yield* MockHttpClient.requests
367 assert.isAtLeast(requests.length, 2)
368
369 const firstMetric = findMetric(requests[0], metricName)
370 const firstDataPoints = firstMetric?.sum?.dataPoints
371 const firstA = firstDataPoints?.find((dp) =>
372 dp.attributes.some((attr) =>
373 attr.key === "key" &&
374 Predicate.hasProperty(attr.value, "stringValue") &&
375 attr.value.stringValue === "a"
376 )
377 )
378 const firstB = firstDataPoints?.find((dp) =>
379 dp.attributes.some((attr) =>
380 attr.key === "key" &&
381 Predicate.hasProperty(attr.value, "stringValue") &&
382 attr.value.stringValue === "b"
383 )
384 )
385 assert.strictEqual(firstA?.asInt, 2)
386 assert.strictEqual(firstB?.asInt, 1)
387
388 const secondMetric = findMetric(requests[1], metricName)
389 assert.isDefined(secondMetric)
390 const secondDataPoints = secondMetric?.sum?.dataPoints
391 const secondA = secondDataPoints?.find((dp) =>
392 dp.attributes.some((attr) =>
393 attr.key === "key" &&
394 Predicate.hasProperty(attr.value, "stringValue") &&
395 attr.value.stringValue === "a"
396 )
397 )
398 const secondB = secondDataPoints?.find((dp) =>
399 dp.attributes.some((attr) =>
400 attr.key === "key" &&
401 Predicate.hasProperty(attr.value, "stringValue") &&
402 attr.value.stringValue === "b"
403 )
404 )
405 assert.strictEqual(secondA?.asInt, 1) // Delta: 3-2=1
406 assert.strictEqual(secondB?.asInt, 2) // Delta: 3-1=2
407 }).pipe(Effect.provide(TestLayerDelta)))
408
409 it.effect("reports summary count and sum deltas across export intervals", () =>
410 Effect.gen(function*() {
411 const metricName = "delta_summary_test"
412 const summary = Metric.summary(metricName, {
413 description: "Test summary",
414 maxAge: "1 minute",
415 maxSize: 100,
416 quantiles: [0.5, 0.9, 0.99]
417 })
418
419 // First interval: observe 10, 20, 30 (count=3, sum=60)
420 yield* Metric.update(summary, 10)
421 yield* Metric.update(summary, 20)
422 yield* Metric.update(summary, 30)
423 yield* triggerExport
424
425 // Second interval: observe 40 (delta count=1, delta sum=40)
426 yield* Metric.update(summary, 40)
427 yield* triggerExport
428
429 const requests = yield* MockHttpClient.requests
430 assert.isAtLeast(requests.length, 2)
431
432 // First export
433 const firstCount = findMetric(requests[0], `${metricName}_count`)
434 assert.strictEqual(firstCount?.sum?.dataPoints[0].asInt, 3)
435
436 const firstSum = findMetric(requests[0], `${metricName}_sum`)
437 assert.strictEqual(firstSum?.sum?.dataPoints[0].asDouble, 60)
438
439 // Second export should report delta values
440 const secondCount = findMetric(requests[1], `${metricName}_count`)
441 assert.strictEqual(secondCount?.sum?.dataPoints[0].asInt, 1) // Delta: 4-3=1
442
443 const secondSum = findMetric(requests[1], `${metricName}_sum`)
444 assert.strictEqual(secondSum?.sum?.dataPoints[0].asDouble, 40) // Delta: 100-60=40
445 }).pipe(Effect.provide(TestLayerDelta)))
446 })
447
448 describe("Summary", () => {
449 it.effect("exports quantile metrics", () =>
450 Effect.gen(function*() {
451 const metricName = "summary_quantiles_test"
452 const summary = Metric.summary(metricName, {
453 description: "Test summary",
454 maxAge: "1 minute",
455 maxSize: 100,
456 quantiles: [0.5, 0.9]
457 })
458
459 yield* Metric.update(summary, 10)
460 yield* Metric.update(summary, 20)
461 yield* Metric.update(summary, 30)
462 yield* triggerExport
463
464 const requests = yield* MockHttpClient.requests
465 assert.isAtLeast(requests.length, 1)
466
467 // Check that quantiles metric exists
468 const quantilesMetric = findMetric(requests[0], `${metricName}_quantiles`)
469 assert.isDefined(quantilesMetric)
470 assert.isDefined(quantilesMetric?.sum)
471
472 // Should have data points for min, max, and each quantile (0.5, 0.9)
473 const dataPoints = quantilesMetric?.sum?.dataPoints ?? []
474 assert.isAtLeast(dataPoints.length, 4) // min, 0.5, 0.9, max
475
476 // Find min and max quantile data points
477 const minPoint = dataPoints.find((dp) =>
478 dp.attributes.some((attr) =>
479 attr.key === "quantile" &&
480 Predicate.hasProperty(attr.value, "stringValue") &&
481 attr.value.stringValue === "min"
482 )
483 )
484 const maxPoint = dataPoints.find((dp) =>
485 dp.attributes.some((attr) =>
486 attr.key === "quantile" &&
487 Predicate.hasProperty(attr.value, "stringValue") &&
488 attr.value.stringValue === "max"
489 )
490 )
491 assert.strictEqual(minPoint?.asDouble, 10)
492 assert.strictEqual(maxPoint?.asDouble, 30)
493 }).pipe(Effect.provide(TestLayerCumulative)))
494 })
495
496 describe("Gauge (no temporality)", () => {
497 it.effect.each([
498 ["cumulative", TestLayerCumulative] as const,
499 ["delta", TestLayerDelta] as const
500 ])("%s temporality reports current value", ([_, layer]) =>
501 Effect.gen(function*() {
502 const metricName = "delta_gauge_test"
503
504 const gauge = Metric.gauge(metricName, {
505 description: "Test gauge"
506 })
507
508 yield* Metric.update(gauge, 100)
509 yield* triggerExport
510
511 yield* Metric.update(gauge, 50)
512 yield* triggerExport
513
514 const requests = yield* MockHttpClient.requests
515 assert.isAtLeast(requests.length, 2)
516
517 // First export should report current value 100
518 const firstMetric = findMetric(requests[0], metricName)
519 assert.strictEqual(firstMetric?.gauge?.dataPoints[0].asDouble, 100)
520
521 // Second export should report current value 50 (not delta -50)
522 const secondMetric = findMetric(requests[1], metricName)
523 assert.strictEqual(secondMetric?.gauge?.dataPoints[0].asDouble, 50)
524 }).pipe(
525 Effect.provide(layer),
526 Effect.provideService(Metric.MetricRegistry, new Map())
527 ))
528 })
529})
530
531interface OtlpExportRequest {
532 readonly resourceMetrics: Array<{
533 readonly resource?: unknown
534 readonly scopeMetrics: Array<{
535 readonly scope?: unknown | undefined
536 readonly metrics: Array<OtlpMetric>
537 }>
538 }>
539}
540
541interface OtlpMetric {
542 readonly name: string
543 readonly description?: string | undefined
544 readonly unit?: string | undefined
545 readonly sum?: {
546 readonly dataPoints: Array<OtlpNumberDataPoint>
547 readonly aggregationTemporality: number
548 readonly isMonotonic: boolean
549 } | undefined
550 readonly gauge?: {
551 readonly dataPoints: Array<OtlpNumberDataPoint>
552 } | undefined
553 readonly histogram?: {
554 readonly dataPoints: Array<OtlpHistogramDataPoint>
555 readonly aggregationTemporality: number
556 } | undefined
557}
558
559interface OtlpNumberDataPoint {
560 readonly attributes: Array<{ key: string; value: unknown }>
561 readonly startTimeUnixNano?: string | undefined
562 readonly timeUnixNano?: string | undefined
563 readonly asDouble?: number | undefined
564 readonly asInt?: number | undefined
565}
566
567interface OtlpHistogramDataPoint {
568 readonly attributes?: Array<{ key: string; value: unknown }>
569 readonly startTimeUnixNano?: string | undefined
570 readonly timeUnixNano?: string | undefined
571 readonly count?: number | undefined
572 readonly sum?: number | undefined
573 readonly bucketCounts?: Array<number> | undefined
574 readonly explicitBounds?: Array<number> | undefined
575 readonly min?: number | undefined
576 readonly max?: number | undefined
577}
578
579class MockHttpClient extends Context.Service<MockHttpClient, {
580 readonly requests: Effect.Effect<ReadonlyArray<OtlpExportRequest>>
581}>()("MockHttpClient") {
582 static requests = Effect.service(MockHttpClient).pipe(
583 Effect.flatMap((client) => client.requests)
584 )
585}
586
587const makeHttpClient = Effect.gen(function*() {
588 const capturedRequests = yield* Ref.make<ReadonlyArray<OtlpExportRequest>>([])
589
590 const httpClient = HttpClient.makeWith(
591 Effect.fnUntraced(function*(requestEffect) {
592 const request = yield* requestEffect
593 const body = (request.body._tag === "Uint8Array"
594 ? JSON.parse(new TextDecoder().decode(request.body.body))
595 : {}) as OtlpExportRequest
596 yield* Ref.update(capturedRequests, Array.append(body))
597 return HttpClientResponse.fromWeb(request, new Response())
598 }),
599 Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
600 )
601
602 return Context.make(HttpClient.HttpClient, httpClient).pipe(
603 Context.add(MockHttpClient, MockHttpClient.of({ requests: Ref.get(capturedRequests) }))
604 )
605})
606
607const HttpClientLayer = Layer.effectContext(makeHttpClient)
608
609const OtlpCumulativeMetricsLayer = OtlpMetrics.layer({
610 url: "http://localhost:4318/v1/metrics",
611 resource: { serviceName: "test-service" },
612 exportInterval: "10 seconds"
613})
614
615const OtlpDeltaMetricsLayer = OtlpMetrics.layer({
616 url: "http://localhost:4318/v1/metrics",
617 resource: { serviceName: "test-service" },
618 temporality: "delta",
619 exportInterval: "10 seconds"
620})
621
622const TestLayerCumulative = OtlpCumulativeMetricsLayer.pipe(
623 Layer.provideMerge(HttpClientLayer),
624 Layer.provide(OtlpSerialization.layerJson)
625)
626
627const TestLayerDelta = OtlpDeltaMetricsLayer.pipe(
628 Layer.provideMerge(HttpClientLayer),
629 Layer.provide(OtlpSerialization.layerJson)
630)
631
632const triggerExport = TestClock.adjust("10 seconds")
633
634const findMetric = (request: OtlpExportRequest, name: string): OtlpMetric | undefined => {
635 for (const resourceMetrics of request.resourceMetrics) {
636 for (const scopeMetrics of resourceMetrics.scopeMetrics) {
637 for (const metric of scopeMetrics.metrics) {
638 if (metric.name === name) {
639 return metric
640 }
641 }
642 }
643 }
644 return undefined
645}
646
647const findFrequencyValue = (request: OtlpExportRequest, name: string, key: string): number | undefined =>
648 findMetric(request, name)?.sum?.dataPoints.find((dataPoint) =>
649 dataPoint.attributes.some((attribute) =>
650 attribute.key === "key" &&
651 Predicate.hasProperty(attribute.value, "stringValue") &&
652 attribute.value.stringValue === key
653 )
654 )?.asInt