1import { assert, describe, it } from "@effect/vitest"
2import { Array as Arr, ConfigProvider, Context, Effect, Layer, Metric, Ref } from "effect"
3import { HttpClient, type HttpClientError, HttpClientResponse } from "effect/http"
4import { Otlp, OtlpSerialization } from "effect/observability"
5import { TestClock } from "effect/testing"
6
7describe("Otlp.layerFromConfig", () => {
8 it.effect("configures exporters from OTEL environment variables", () =>
9 Effect.gen(function*() {
10 yield* Effect.log("configured log")
11 yield* Metric.update(Metric.counter("otel_config_counter"), 1)
12 yield* Effect.void.pipe(Effect.withSpan("configured-span"))
13
14 yield* TestClock.adjust("1 second")
15
16 const requests = yield* MockHttpClient.requests
17 const logRequest = findRequest(requests, "http://logs.example/custom")
18 const metricRequest = findRequest(requests, "http://collector.example/v1/metrics")
19 const traceRequest = findRequest(requests, "http://collector.example/v1/traces")
20
21 assert.isDefined(logRequest)
22 assert.strictEqual(logRequest!.headers["x-signal"], "logs")
23 assert.strictEqual(logRequest!.headers["x-shared"], undefined)
24
25 assert.isDefined(metricRequest)
26 assert.strictEqual(metricRequest!.headers["x-shared"], "shared")
27 const metric = findMetric(metricRequest!.body as OtlpMetricsBody, "otel_config_counter")
28 assert.strictEqual(metric?.sum?.aggregationTemporality, 1)
29
30 assert.isDefined(traceRequest)
31 assert.strictEqual(traceRequest!.headers["x-shared"], "shared")
32 }).pipe(
33 Effect.provide(testLayer({
34 OTEL_SERVICE_NAME: "otel-config-test",
35 OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector.example",
36 OTEL_EXPORTER_OTLP_HEADERS: "x-shared=shared",
37 OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: "http://logs.example/custom",
38 OTEL_EXPORTER_OTLP_LOGS_HEADERS: "x-signal=logs",
39 OTEL_LOGS_EXPORTER: "console, OTLP",
40 OTEL_METRICS_EXPORTER: " otlp ",
41 OTEL_TRACES_EXPORTER: "otlp",
42 OTEL_BLRP_MAX_EXPORT_BATCH_SIZE: "1",
43 OTEL_BSP_MAX_EXPORT_BATCH_SIZE: "1",
44 OTEL_METRIC_EXPORT_INTERVAL: "1000",
45 OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "delta"
46 }))
47 ))
48
49 it.effect("does not install exporters when OTEL_SDK_DISABLED is true", () =>
50 Effect.gen(function*() {
51 yield* Effect.log("disabled log")
52 yield* Metric.update(Metric.counter("otel_disabled_counter"), 1)
53 yield* Effect.void.pipe(Effect.withSpan("disabled-span"))
54
55 yield* TestClock.adjust("1 second")
56
57 const requests = yield* MockHttpClient.requests
58 assert.deepStrictEqual(requests, [])
59 }).pipe(
60 Effect.provide(testLayer({
61 OTEL_SDK_DISABLED: "true",
62 OTEL_SERVICE_NAME: "otel-config-test",
63 OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector.example",
64 OTEL_LOGS_EXPORTER: "otlp",
65 OTEL_METRICS_EXPORTER: "otlp",
66 OTEL_TRACES_EXPORTER: "otlp",
67 OTEL_BLRP_MAX_EXPORT_BATCH_SIZE: "1",
68 OTEL_BSP_MAX_EXPORT_BATCH_SIZE: "1",
69 OTEL_METRIC_EXPORT_INTERVAL: "1000"
70 }))
71 ))
72})
73
74interface CapturedRequest {
75 readonly url: string
76 readonly headers: Record<string, string>
77 readonly body: unknown
78}
79
80interface OtlpMetricsBody {
81 readonly resourceMetrics: Array<{
82 readonly scopeMetrics: Array<{
83 readonly metrics: Array<OtlpMetric>
84 }>
85 }>
86}
87
88interface OtlpMetric {
89 readonly name: string
90 readonly sum?: {
91 readonly aggregationTemporality: number
92 } | undefined
93}
94
95class MockHttpClient extends Context.Service<MockHttpClient, {
96 readonly requests: Effect.Effect<ReadonlyArray<CapturedRequest>>
97}>()("MockHttpClient") {
98 static requests = Effect.service(MockHttpClient).pipe(
99 Effect.flatMap((client) => client.requests)
100 )
101}
102
103const testLayer = (env: Record<string, string>) =>
104 Otlp.layerFromConfig({ loggerMergeWithExisting: false }).pipe(
105 Layer.provide(OtlpSerialization.layerJson),
106 Layer.provideMerge(HttpClientLayer),
107 Layer.provideMerge(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))
108 )
109
110const HttpClientLayer = Layer.effectContext(Effect.gen(function*() {
111 const capturedRequests = yield* Ref.make<ReadonlyArray<CapturedRequest>>([])
112
113 const httpClient = HttpClient.makeWith(
114 Effect.fnUntraced(function*(requestEffect) {
115 const request = yield* requestEffect
116 const body = request.body._tag === "Uint8Array"
117 ? JSON.parse(new TextDecoder().decode(request.body.body))
118 : undefined
119 yield* Ref.update(
120 capturedRequests,
121 Arr.append({
122 url: request.url,
123 headers: request.headers,
124 body
125 })
126 )
127 return HttpClientResponse.fromWeb(request, new Response())
128 }),
129 Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
130 )
131
132 return Context.make(HttpClient.HttpClient, httpClient).pipe(
133 Context.add(MockHttpClient, MockHttpClient.of({ requests: Ref.get(capturedRequests) }))
134 )
135}))
136
137const findRequest = (requests: ReadonlyArray<CapturedRequest>, url: string) =>
138 requests.find((request) => request.url === url)
139
140const findMetric = (request: OtlpMetricsBody, name: string): OtlpMetric | undefined => {
141 for (const resourceMetrics of request.resourceMetrics) {
142 for (const scopeMetrics of resourceMetrics.scopeMetrics) {
143 for (const metric of scopeMetrics.metrics) {
144 if (metric.name === name) {
145 return metric
146 }
147 }
148 }
149 }
150 return undefined
151}