import { assert, describe, it } from "@effect/vitest" import { Array, Context, Effect, Layer, Metric, Predicate, Ref } from "effect" import { TestClock } from "effect/testing" import { HttpClient, type HttpClientError, HttpClientResponse } from "effect/unstable/http" import { OtlpMetrics, OtlpSerialization } from "effect/unstable/observability" describe("OtlpMetrics", () => { describe("cumulative temporality", () => { it.effect("reports counter totals across export intervals", () => Effect.gen(function*() { const metricName = "cumulative_counter_test" const counter = Metric.counter(metricName, { description: "Test counter" }) // First interval: increment by 5 yield* Metric.update(counter, 5) yield* triggerExport // Second interval: increment by 3 more (total: 8) yield* Metric.update(counter, 3) yield* triggerExport const requests = yield* MockHttpClient.requests assert.isAtLeast(requests.length, 2) const firstMetric = findMetric(requests[0], metricName) assert.strictEqual(firstMetric?.sum?.dataPoints[0].asDouble, 5) // Second export should report cumulative 8 const secondMetric = findMetric(requests[1], metricName) assert.strictEqual(secondMetric?.sum?.dataPoints[0].asDouble, 8) }).pipe(Effect.provide(TestLayerCumulative))) it.effect("reports histogram count and sum across export intervals", () => Effect.gen(function*() { const metricName = "cumulative_histogram_test" const histogram = Metric.histogram(metricName, { description: "Test histogram", boundaries: [10, 50, 100] }) // First interval: observe values 25, 75 (count=2, sum=100) yield* Metric.update(histogram, 25) yield* Metric.update(histogram, 75) yield* triggerExport // Second interval: observe value 30 (cumulative count=3, sum=130) yield* Metric.update(histogram, 30) yield* triggerExport const requests = yield* MockHttpClient.requests assert.isAtLeast(requests.length, 2) const firstMetric = findMetric(requests[0], metricName) assert.strictEqual(firstMetric?.histogram?.dataPoints[0].count, 2) assert.strictEqual(firstMetric?.histogram?.dataPoints[0].sum, 100) const secondMetric = findMetric(requests[1], metricName) assert.isDefined(secondMetric) assert.strictEqual(secondMetric?.histogram?.dataPoints[0].count, 3) assert.strictEqual(secondMetric?.histogram?.dataPoints[0].sum, 130) }).pipe(Effect.provide(TestLayerCumulative))) it.effect("reports frequency counts across export intervals", () => Effect.gen(function*() { const metricName = "cumulative_frequency_test" const frequency = Metric.frequency(metricName, { description: "Test frequency" }) // First export interval: "a" x 2, "b" x 1 yield* Metric.update(frequency, "a") yield* Metric.update(frequency, "a") yield* Metric.update(frequency, "b") yield* triggerExport // Second export interval: "a" x 1, "b" x 2 more (cumulative: "a"=3, "b"=3) yield* Metric.update(frequency, "a") yield* Metric.update(frequency, "b") yield* Metric.update(frequency, "b") yield* triggerExport const requests = yield* MockHttpClient.requests assert.isAtLeast(requests.length, 2) const firstMetric = findMetric(requests[0], metricName) const firstDataPoints = firstMetric?.sum?.dataPoints const firstA = firstDataPoints?.find((dp) => dp.attributes.some((attr) => attr.key === "key" && Predicate.hasProperty(attr.value, "stringValue") && attr.value.stringValue === "a" ) ) const firstB = firstDataPoints?.find((dp) => dp.attributes.some((attr) => attr.key === "key" && Predicate.hasProperty(attr.value, "stringValue") && attr.value.stringValue === "b" ) ) assert.strictEqual(firstA?.asInt, 2) assert.strictEqual(firstB?.asInt, 1) // Second export should report cumulative values const secondMetric = findMetric(requests[1], metricName) assert.isDefined(secondMetric) const secondDataPoints = secondMetric?.sum?.dataPoints const secondA = secondDataPoints?.find((dp) => dp.attributes.some((attr) => attr.key === "key" && Predicate.hasProperty(attr.value, "stringValue") && attr.value.stringValue === "a" ) ) const secondB = secondDataPoints?.find((dp) => dp.attributes.some((attr) => attr.key === "key" && Predicate.hasProperty(attr.value, "stringValue") && attr.value.stringValue === "b" ) ) assert.strictEqual(secondA?.asInt, 3) // Cumulative: 2+1=3 assert.strictEqual(secondB?.asInt, 3) // Cumulative: 1+2=3 }).pipe(Effect.provide(TestLayerCumulative))) it.effect("reports summary count and sum across export intervals", () => Effect.gen(function*() { const metricName = "cumulative_summary_test" const summary = Metric.summary(metricName, { description: "Test summary", maxAge: "1 minute", maxSize: 100, quantiles: [0.5, 0.9, 0.99] }) // First interval: observe 10, 20, 30 (count=3, sum=60) yield* Metric.update(summary, 10) yield* Metric.update(summary, 20) yield* Metric.update(summary, 30) yield* triggerExport // Second interval: observe 40 (cumulative count=4, sum=100) yield* Metric.update(summary, 40) yield* triggerExport const requests = yield* MockHttpClient.requests assert.isAtLeast(requests.length, 2) // First export - check count and sum metrics const firstCount = findMetric(requests[0], `${metricName}_count`) assert.strictEqual(firstCount?.sum?.dataPoints[0].asInt, 3) const firstSum = findMetric(requests[0], `${metricName}_sum`) assert.strictEqual(firstSum?.sum?.dataPoints[0].asDouble, 60) // Second export should report cumulative values const secondCount = findMetric(requests[1], `${metricName}_count`) assert.strictEqual(secondCount?.sum?.dataPoints[0].asInt, 4) const secondSum = findMetric(requests[1], `${metricName}_sum`) assert.strictEqual(secondSum?.sum?.dataPoints[0].asDouble, 100) }).pipe(Effect.provide(TestLayerCumulative))) }) describe("delta temporality", () => { it.effect("reports counter deltas across export intervals", () => Effect.gen(function*() { const metricName = "delta_counter_test" const counter = Metric.counter(metricName, { description: "Test counter" }) // First export interval - increment the counter by 5 yield* Metric.update(counter, 5) yield* triggerExport // Second export interval - increment the counter by 3 more (total: 8) yield* Metric.update(counter, 3) yield* triggerExport const requests = yield* MockHttpClient.requests assert.isAtLeast(requests.length, 2) const firstMetric = findMetric(requests[0], metricName) assert.strictEqual(firstMetric?.sum?.dataPoints[0].asDouble, 5) const secondMetric = findMetric(requests[1], metricName) assert.strictEqual(secondMetric?.sum?.dataPoints[0].asDouble, 3) }).pipe(Effect.provide(TestLayerDelta))) it.effect("reports histogram count and sum deltas across export intervals", () => Effect.gen(function*() { const metricName = "delta_histogram_test" const histogram = Metric.histogram(metricName, { description: "Test histogram", boundaries: [10, 50, 100] }) // First export interval: observe values 25, 75 (count=2, sum=100) yield* Metric.update(histogram, 25) yield* Metric.update(histogram, 75) yield* triggerExport // Second export interval: observe value 30 (delta count=1, delta sum=30) yield* Metric.update(histogram, 30) yield* triggerExport const requests = yield* MockHttpClient.requests assert.isAtLeast(requests.length, 2) const firstMetric = findMetric(requests[0], metricName) assert.strictEqual(firstMetric?.histogram?.dataPoints[0].count, 2) assert.strictEqual(firstMetric!.histogram!.dataPoints[0].sum, 100) const secondMetric = findMetric(requests[1], metricName) assert.strictEqual(secondMetric?.histogram?.dataPoints[0].count, 1) assert.strictEqual(secondMetric?.histogram?.dataPoints[0].sum, 30) }).pipe(Effect.provide(TestLayerDelta))) it.effect("reports frequency count deltas across export intervals", () => Effect.gen(function*() { const metricName = "delta_frequency_test" const frequency = Metric.frequency(metricName, { description: "Test frequency" }) // First export interval: "a" x 2, "b" x 1 yield* Metric.update(frequency, "a") yield* Metric.update(frequency, "a") yield* Metric.update(frequency, "b") yield* triggerExport // Second export interval: "a" x 1, "b" x 2 more yield* Metric.update(frequency, "a") yield* Metric.update(frequency, "b") yield* Metric.update(frequency, "b") yield* triggerExport const requests = yield* MockHttpClient.requests assert.isAtLeast(requests.length, 2) const firstMetric = findMetric(requests[0], metricName) const firstDataPoints = firstMetric?.sum?.dataPoints const firstA = firstDataPoints?.find((dp) => dp.attributes.some((attr) => attr.key === "key" && Predicate.hasProperty(attr.value, "stringValue") && attr.value.stringValue === "a" ) ) const firstB = firstDataPoints?.find((dp) => dp.attributes.some((attr) => attr.key === "key" && Predicate.hasProperty(attr.value, "stringValue") && attr.value.stringValue === "b" ) ) assert.strictEqual(firstA?.asInt, 2) assert.strictEqual(firstB?.asInt, 1) const secondMetric = findMetric(requests[1], metricName) assert.isDefined(secondMetric) const secondDataPoints = secondMetric?.sum?.dataPoints const secondA = secondDataPoints?.find((dp) => dp.attributes.some((attr) => attr.key === "key" && Predicate.hasProperty(attr.value, "stringValue") && attr.value.stringValue === "a" ) ) const secondB = secondDataPoints?.find((dp) => dp.attributes.some((attr) => attr.key === "key" && Predicate.hasProperty(attr.value, "stringValue") && attr.value.stringValue === "b" ) ) assert.strictEqual(secondA?.asInt, 1) // Delta: 3-2=1 assert.strictEqual(secondB?.asInt, 2) // Delta: 3-1=2 }).pipe(Effect.provide(TestLayerDelta))) it.effect("reports summary count and sum deltas across export intervals", () => Effect.gen(function*() { const metricName = "delta_summary_test" const summary = Metric.summary(metricName, { description: "Test summary", maxAge: "1 minute", maxSize: 100, quantiles: [0.5, 0.9, 0.99] }) // First interval: observe 10, 20, 30 (count=3, sum=60) yield* Metric.update(summary, 10) yield* Metric.update(summary, 20) yield* Metric.update(summary, 30) yield* triggerExport // Second interval: observe 40 (delta count=1, delta sum=40) yield* Metric.update(summary, 40) yield* triggerExport const requests = yield* MockHttpClient.requests assert.isAtLeast(requests.length, 2) // First export const firstCount = findMetric(requests[0], `${metricName}_count`) assert.strictEqual(firstCount?.sum?.dataPoints[0].asInt, 3) const firstSum = findMetric(requests[0], `${metricName}_sum`) assert.strictEqual(firstSum?.sum?.dataPoints[0].asDouble, 60) // Second export should report delta values const secondCount = findMetric(requests[1], `${metricName}_count`) assert.strictEqual(secondCount?.sum?.dataPoints[0].asInt, 1) // Delta: 4-3=1 const secondSum = findMetric(requests[1], `${metricName}_sum`) assert.strictEqual(secondSum?.sum?.dataPoints[0].asDouble, 40) // Delta: 100-60=40 }).pipe(Effect.provide(TestLayerDelta))) }) describe("Summary", () => { it.effect("exports quantile metrics", () => Effect.gen(function*() { const metricName = "summary_quantiles_test" const summary = Metric.summary(metricName, { description: "Test summary", maxAge: "1 minute", maxSize: 100, quantiles: [0.5, 0.9] }) yield* Metric.update(summary, 10) yield* Metric.update(summary, 20) yield* Metric.update(summary, 30) yield* triggerExport const requests = yield* MockHttpClient.requests assert.isAtLeast(requests.length, 1) // Check that quantiles metric exists const quantilesMetric = findMetric(requests[0], `${metricName}_quantiles`) assert.isDefined(quantilesMetric) assert.isDefined(quantilesMetric?.sum) // Should have data points for min, max, and each quantile (0.5, 0.9) const dataPoints = quantilesMetric?.sum?.dataPoints ?? [] assert.isAtLeast(dataPoints.length, 4) // min, 0.5, 0.9, max // Find min and max quantile data points const minPoint = dataPoints.find((dp) => dp.attributes.some((attr) => attr.key === "quantile" && Predicate.hasProperty(attr.value, "stringValue") && attr.value.stringValue === "min" ) ) const maxPoint = dataPoints.find((dp) => dp.attributes.some((attr) => attr.key === "quantile" && Predicate.hasProperty(attr.value, "stringValue") && attr.value.stringValue === "max" ) ) assert.strictEqual(minPoint?.asDouble, 10) assert.strictEqual(maxPoint?.asDouble, 30) }).pipe(Effect.provide(TestLayerCumulative))) }) describe("Gauge (no temporality)", () => { it.effect.each([ ["cumulative", TestLayerCumulative] as const, ["delta", TestLayerDelta] as const ])("%s temporality reports current value", ([_, layer]) => Effect.gen(function*() { const metricName = "delta_gauge_test" const gauge = Metric.gauge(metricName, { description: "Test gauge" }) yield* Metric.update(gauge, 100) yield* triggerExport yield* Metric.update(gauge, 50) yield* triggerExport const requests = yield* MockHttpClient.requests assert.isAtLeast(requests.length, 2) // First export should report current value 100 const firstMetric = findMetric(requests[0], metricName) assert.strictEqual(firstMetric?.gauge?.dataPoints[0].asDouble, 100) // Second export should report current value 50 (not delta -50) const secondMetric = findMetric(requests[1], metricName) assert.strictEqual(secondMetric?.gauge?.dataPoints[0].asDouble, 50) }).pipe(Effect.provide(layer))) }) }) interface OtlpExportRequest { readonly resourceMetrics: Array<{ readonly resource?: unknown readonly scopeMetrics: Array<{ readonly scope?: unknown | undefined readonly metrics: Array }> }> } interface OtlpMetric { readonly name: string readonly description?: string | undefined readonly unit?: string | undefined readonly sum?: { readonly dataPoints: Array readonly aggregationTemporality: number readonly isMonotonic: boolean } | undefined readonly gauge?: { readonly dataPoints: Array } | undefined readonly histogram?: { readonly dataPoints: Array readonly aggregationTemporality: number } | undefined } interface OtlpNumberDataPoint { readonly attributes: Array<{ key: string; value: unknown }> readonly startTimeUnixNano?: string | undefined readonly timeUnixNano?: string | undefined readonly asDouble?: number | undefined readonly asInt?: number | undefined } interface OtlpHistogramDataPoint { readonly attributes?: Array<{ key: string; value: unknown }> readonly startTimeUnixNano?: string | undefined readonly timeUnixNano?: string | undefined readonly count?: number | undefined readonly sum?: number | undefined readonly bucketCounts?: Array | undefined readonly explicitBounds?: Array | undefined readonly min?: number | undefined readonly max?: number | undefined } class MockHttpClient extends Context.Service> }>()("MockHttpClient") { static requests = Effect.service(MockHttpClient).pipe( Effect.flatMap((client) => client.requests) ) } const makeHttpClient = Effect.gen(function*() { const capturedRequests = yield* Ref.make>([]) const httpClient = HttpClient.makeWith( Effect.fnUntraced(function*(requestEffect) { const request = yield* requestEffect const body = (request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {}) as OtlpExportRequest yield* Ref.update(capturedRequests, Array.append(body)) return HttpClientResponse.fromWeb(request, new Response()) }), Effect.succeed as HttpClient.HttpClient.Preprocess ) return Context.make(HttpClient.HttpClient, httpClient).pipe( Context.add(MockHttpClient, MockHttpClient.of({ requests: Ref.get(capturedRequests) })) ) }) const HttpClientLayer = Layer.effectContext(makeHttpClient) const OtlpCumulativeMetricsLayer = OtlpMetrics.layer({ url: "http://localhost:4318/v1/metrics", resource: { serviceName: "test-service" }, exportInterval: "10 seconds" }) const OtlpDeltaMetricsLayer = OtlpMetrics.layer({ url: "http://localhost:4318/v1/metrics", resource: { serviceName: "test-service" }, temporality: "delta", exportInterval: "10 seconds" }) const TestLayerCumulative = OtlpCumulativeMetricsLayer.pipe( Layer.provideMerge(HttpClientLayer), Layer.provide(OtlpSerialization.layerJson) ) const TestLayerDelta = OtlpDeltaMetricsLayer.pipe( Layer.provideMerge(HttpClientLayer), Layer.provide(OtlpSerialization.layerJson) ) const triggerExport = TestClock.adjust("10 seconds") const findMetric = (request: OtlpExportRequest, name: string): OtlpMetric | undefined => { for (const resourceMetrics of request.resourceMetrics) { for (const scopeMetrics of resourceMetrics.scopeMetrics) { for (const metric of scopeMetrics.metrics) { if (metric.name === name) { return metric } } } } return undefined }