519 lines
19 KiB
TypeScript
519 lines
19 KiB
TypeScript
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<OtlpMetric>
|
|
}>
|
|
}>
|
|
}
|
|
|
|
interface OtlpMetric {
|
|
readonly name: string
|
|
readonly description?: string | undefined
|
|
readonly unit?: string | undefined
|
|
readonly sum?: {
|
|
readonly dataPoints: Array<OtlpNumberDataPoint>
|
|
readonly aggregationTemporality: number
|
|
readonly isMonotonic: boolean
|
|
} | undefined
|
|
readonly gauge?: {
|
|
readonly dataPoints: Array<OtlpNumberDataPoint>
|
|
} | undefined
|
|
readonly histogram?: {
|
|
readonly dataPoints: Array<OtlpHistogramDataPoint>
|
|
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<number> | undefined
|
|
readonly explicitBounds?: Array<number> | undefined
|
|
readonly min?: number | undefined
|
|
readonly max?: number | undefined
|
|
}
|
|
|
|
class MockHttpClient extends Context.Service<MockHttpClient, {
|
|
readonly requests: Effect.Effect<ReadonlyArray<OtlpExportRequest>>
|
|
}>()("MockHttpClient") {
|
|
static requests = Effect.service(MockHttpClient).pipe(
|
|
Effect.flatMap((client) => client.requests)
|
|
)
|
|
}
|
|
|
|
const makeHttpClient = Effect.gen(function*() {
|
|
const capturedRequests = yield* Ref.make<ReadonlyArray<OtlpExportRequest>>([])
|
|
|
|
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<HttpClientError.HttpClientError, never>
|
|
)
|
|
|
|
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
|
|
}
|