| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { Array as Arr, ConfigProvider, Context, Effect, Layer, Metric, Ref } from "effect" |
| 3 | import { HttpClient, type HttpClientError, HttpClientResponse } from "effect/http" |
| 4 | import { Otlp, OtlpSerialization } from "effect/observability" |
| 5 | import { TestClock } from "effect/testing" |
| 6 | |
| 7 | describe("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 | |
| 74 | interface CapturedRequest { |
| 75 | readonly url: string |
| 76 | readonly headers: Record<string, string> |
| 77 | readonly body: unknown |
| 78 | } |
| 79 | |
| 80 | interface OtlpMetricsBody { |
| 81 | readonly resourceMetrics: Array<{ |
| 82 | readonly scopeMetrics: Array<{ |
| 83 | readonly metrics: Array<OtlpMetric> |
| 84 | }> |
| 85 | }> |
| 86 | } |
| 87 | |
| 88 | interface OtlpMetric { |
| 89 | readonly name: string |
| 90 | readonly sum?: { |
| 91 | readonly aggregationTemporality: number |
| 92 | } | undefined |
| 93 | } |
| 94 | |
| 95 | class 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 | |
| 103 | const 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 | |
| 110 | const 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 | |
| 137 | const findRequest = (requests: ReadonlyArray<CapturedRequest>, url: string) => |
| 138 | requests.find((request) => request.url === url) |
| 139 | |
| 140 | const 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 | } |