1import { assert, describe, it } from "@effect/vitest";
2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as OtlpResource from "effect/observability/OtlpResource";
6
7import * as OtelEnvironment from "./otelEnvironment.ts";
8
9const load = (env: Record<string, string>) =>
10 OtelEnvironment.load.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))));
11
12const SPEC_OFF =
13 "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway";
14const T3_OFF =
15 "T3CODE_OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it";
16const specIgnored = (value: string) =>
17 `OTEL_SDK_DISABLED=${value} was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way`;
18
19describe("OtelEnvironment", () => {
20 it.effect.each([
21 { name: "nothing set", env: {}, disabled: false, warnings: [] },
22 // OTEL_SDK_DISABLED follows the specification: only `true`, case-insensitively.
23 { name: "spec true", env: { OTEL_SDK_DISABLED: "true" }, disabled: true, warnings: [SPEC_OFF] },
24 { name: "spec True", env: { OTEL_SDK_DISABLED: "True" }, disabled: true, warnings: [SPEC_OFF] },
25 {
26 name: "spec padded",
27 env: { OTEL_SDK_DISABLED: " true " },
28 disabled: true,
29 warnings: [SPEC_OFF],
30 },
31 { name: "spec false", env: { OTEL_SDK_DISABLED: "false" }, disabled: false, warnings: [] },
32 {
33 name: "spec 1",
34 env: { OTEL_SDK_DISABLED: "1" },
35 disabled: false,
36 warnings: [specIgnored("1")],
37 },
38 {
39 name: "spec padded yes",
40 env: { OTEL_SDK_DISABLED: " yes " },
41 disabled: false,
42 warnings: [specIgnored("yes")],
43 },
44 // T3CODE_OTEL_SDK_DISABLED takes Config.Boolean's values, case-insensitively.
45 { name: "t3 1", env: { T3CODE_OTEL_SDK_DISABLED: "1" }, disabled: true, warnings: [T3_OFF] },
46 {
47 name: "t3 TRUE",
48 env: { T3CODE_OTEL_SDK_DISABLED: "TRUE" },
49 disabled: true,
50 warnings: [T3_OFF],
51 },
52 { name: "t3 n", env: { T3CODE_OTEL_SDK_DISABLED: "n" }, disabled: false, warnings: [] },
53 {
54 name: "t3 false overrides spec true",
55 env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "true" },
56 disabled: false,
57 warnings: [],
58 },
59 {
60 name: "blank t3 falls through",
61 env: { T3CODE_OTEL_SDK_DISABLED: " ", OTEL_SDK_DISABLED: "true" },
62 disabled: true,
63 warnings: [SPEC_OFF],
64 },
65 {
66 name: "unreadable t3 warns and falls through",
67 env: { T3CODE_OTEL_SDK_DISABLED: "maybe", OTEL_SDK_DISABLED: "true" },
68 disabled: true,
69 warnings: ["T3CODE_OTEL_SDK_DISABLED=maybe is not a yes or a no and was ignored", SPEC_OFF],
70 },
71 {
72 name: "bad spec value still warns when t3 answered",
73 env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "yes" },
74 disabled: false,
75 warnings: [specIgnored("yes")],
76 },
77 ])("$name", ({ env, disabled, warnings }) =>
78 Effect.gen(function* () {
79 const resolved = yield* load(env);
80 assert.strictEqual(resolved.disabled, disabled);
81 assert.deepStrictEqual(resolved.warnings, warnings);
82 }),
83 );
84
85 it.effect.each([
86 { name: "unset", env: {}, resourceAttributes: {}, warnings: [] },
87 {
88 name: "a percent-encoded list",
89 env: { OTEL_RESOURCE_ATTRIBUTES: "team=core,message=hello%20world" },
90 resourceAttributes: { team: "core", message: "hello world" },
91 warnings: [],
92 },
93 {
94 name: "a list that does not decode",
95 env: { OTEL_RESOURCE_ATTRIBUTES: "team=core,broken=%zz" },
96 resourceAttributes: {},
97 warnings: [
98 "OTEL_RESOURCE_ATTRIBUTES is not a list of percent-encoded key=value pairs and was ignored",
99 ],
100 },
101 ])("resource attributes: $name", ({ env, resourceAttributes, warnings }) =>
102 Effect.gen(function* () {
103 const resolved = yield* load(env);
104 assert.deepStrictEqual(resolved.resourceAttributes, resourceAttributes);
105 assert.deepStrictEqual(resolved.warnings, warnings);
106 }),
107 );
108
109 describe("endpoints", () => {
110 const urlOf = (signal: OtelEnvironment.OtelSignal) =>
111 OtelEnvironment.OtelSignal.$match(signal, {
112 Export: ({ url }) => url,
113 Off: () => "Off",
114 Unset: () => "Unset",
115 });
116 it.effect.each([
117 {
118 name: "nothing set",
119 env: {},
120 traces: "Unset",
121 metrics: "Unset",
122 logs: "Unset",
123 warnings: [],
124 },
125 {
126 name: "generic endpoint appends each signal's path, keeping the query",
127 env: { OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base?api_key=secret" },
128 traces: "https://collector:4318/base/v1/traces?api_key=secret",
129 metrics: "https://collector:4318/base/v1/metrics?api_key=secret",
130 logs: "https://collector:4318/base/v1/logs?api_key=secret",
131 warnings: [],
132 },
133 {
134 name: "a per-signal endpoint is used verbatim",
135 env: { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/custom" },
136 traces: "https://tempo:4318/custom",
137 metrics: "Unset",
138 logs: "Unset",
139 warnings: [],
140 },
141 {
142 name: "a per-signal endpoint beats the generic one",
143 env: {
144 OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/custom",
145 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base",
146 },
147 traces: "https://tempo:4318/custom",
148 metrics: "https://collector:4318/base/v1/metrics",
149 logs: "https://collector:4318/base/v1/logs",
150 warnings: [],
151 },
152 {
153 name: "a blank per-signal endpoint falls through to the generic one",
154 env: {
155 OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: " ",
156 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base",
157 },
158 traces: "https://collector:4318/base/v1/traces",
159 metrics: "https://collector:4318/base/v1/metrics",
160 logs: "https://collector:4318/base/v1/logs",
161 warnings: [],
162 },
163 {
164 name: "an invalid per-signal endpoint warns and does not fall through",
165 env: {
166 OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "not-a-url",
167 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base",
168 },
169 traces: "Off",
170 metrics: "https://collector:4318/base/v1/metrics",
171 logs: "https://collector:4318/base/v1/logs",
172 warnings: [
173 "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is not an http or https URL, so the signals it configures are not exported",
174 ],
175 },
176 {
177 name: "an invalid generic endpoint warns without leaking its query",
178 env: { OTEL_EXPORTER_OTLP_ENDPOINT: "not-a-url?api_key=secret" },
179 traces: "Off",
180 metrics: "Off",
181 logs: "Off",
182 warnings: [
183 "OTEL_EXPORTER_OTLP_ENDPOINT is not an http or https URL, so the signals it configures are not exported",
184 ],
185 },
186 {
187 name: "an endpoint without a scheme is not an http URL",
188 env: { OTEL_EXPORTER_OTLP_ENDPOINT: "localhost:4318" },
189 traces: "Off",
190 metrics: "Off",
191 logs: "Off",
192 warnings: [
193 "OTEL_EXPORTER_OTLP_ENDPOINT is not an http or https URL, so the signals it configures are not exported",
194 ],
195 },
196 {
197 name: "the kill switch wins outright over a valid endpoint",
198 env: {
199 T3CODE_OTEL_SDK_DISABLED: "true",
200 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base",
201 },
202 traces: "Unset",
203 metrics: "Unset",
204 logs: "Unset",
205 warnings: [T3_OFF],
206 },
207 {
208 name: "an exporter of none turns off only its signal",
209 env: {
210 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318",
211 OTEL_LOGS_EXPORTER: "none",
212 },
213 traces: "https://collector:4318/v1/traces",
214 metrics: "https://collector:4318/v1/metrics",
215 logs: "Off",
216 warnings: [],
217 },
218 {
219 name: "an exporter of none turns its signal off with no endpoint named",
220 env: { OTEL_METRICS_EXPORTER: " NONE " },
221 traces: "Unset",
222 metrics: "Off",
223 logs: "Unset",
224 warnings: [],
225 },
226 {
227 name: "an exporter of none leaves its signal's endpoint unread",
228 env: { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "not-a-url", OTEL_TRACES_EXPORTER: "none" },
229 traces: "Off",
230 metrics: "Unset",
231 logs: "Unset",
232 warnings: [],
233 },
234 {
235 name: "an exporter of otlp or blank is the default",
236 env: {
237 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318",
238 OTEL_TRACES_EXPORTER: "OTLP",
239 OTEL_LOGS_EXPORTER: " ",
240 },
241 traces: "https://collector:4318/v1/traces",
242 metrics: "https://collector:4318/v1/metrics",
243 logs: "https://collector:4318/v1/logs",
244 warnings: [],
245 },
246 {
247 name: "an exporter T3 Code does not have is ignored with a warning",
248 env: {
249 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318",
250 OTEL_METRICS_EXPORTER: "prometheus",
251 },
252 traces: "https://collector:4318/v1/traces",
253 metrics: "https://collector:4318/v1/metrics",
254 logs: "https://collector:4318/v1/logs",
255 warnings: [
256 "OTEL_METRICS_EXPORTER names prometheus, which T3 Code does not export to, so it was ignored",
257 ],
258 },
259 {
260 name: "a list honors none and otlp and names what it ignored",
261 env: {
262 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318",
263 OTEL_TRACES_EXPORTER: "console,none",
264 OTEL_LOGS_EXPORTER: "otlp,console,otlpp",
265 },
266 traces: "Off",
267 metrics: "https://collector:4318/v1/metrics",
268 logs: "https://collector:4318/v1/logs",
269 warnings: [
270 "OTEL_TRACES_EXPORTER names console, which T3 Code does not export to, so it was ignored",
271 "OTEL_LOGS_EXPORTER names console, otlpp, which T3 Code does not export to, so they were ignored",
272 ],
273 },
274 ])("$name", ({ env, traces, metrics, logs, warnings }) =>
275 Effect.gen(function* () {
276 const resolved = yield* load(env);
277 assert.strictEqual(urlOf(resolved.traces), traces);
278 assert.strictEqual(urlOf(resolved.metrics), metrics);
279 assert.strictEqual(urlOf(resolved.logs), logs);
280 assert.deepStrictEqual(resolved.warnings, warnings);
281 }),
282 );
283
284 const ENDPOINT = { OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318" };
285 const exportOf = (signal: OtelEnvironment.OtelSignal): unknown =>
286 OtelEnvironment.OtelSignal.$match(signal, {
287 Export: ({ protocol, headers }) => ({ protocol, headers }),
288 Off: () => "Off",
289 Unset: () => "Unset",
290 });
291 it.effect.each([
292 {
293 name: "nothing else set takes the specification's default protocol",
294 env: ENDPOINT,
295 traces: { protocol: "http/protobuf", headers: undefined },
296 logs: { protocol: "http/protobuf", headers: undefined },
297 warnings: [],
298 },
299 {
300 name: "headers are comma-separated pairs with percent-encoded values",
301 env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS: "api-key=a%20b,tenant=t3" },
302 traces: { protocol: "http/protobuf", headers: { "api-key": "a b", tenant: "t3" } },
303 logs: { protocol: "http/protobuf", headers: { "api-key": "a b", tenant: "t3" } },
304 warnings: [],
305 },
306 {
307 name: "a per-signal protocol and headers beat the generic ones",
308 env: {
309 ...ENDPOINT,
310 OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
311 OTEL_EXPORTER_OTLP_HEADERS: "api-key=shared",
312 OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "http/protobuf",
313 OTEL_EXPORTER_OTLP_TRACES_HEADERS: "api-key=traces%20only",
314 },
315 traces: { protocol: "http/protobuf", headers: { "api-key": "traces only" } },
316 logs: { protocol: "http/json", headers: { "api-key": "shared" } },
317 warnings: [],
318 },
319 {
320 name: "an unsupported protocol turns off the signals it configures",
321 env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc" },
322 traces: { protocol: "http/protobuf", headers: undefined },
323 logs: "Off",
324 warnings: [
325 "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL is not http/protobuf or http/json, so the signals it configures are not exported",
326 ],
327 },
328 {
329 name: "a protocol reads case-insensitively",
330 env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL: "HTTP/JSON" },
331 traces: { protocol: "http/json", headers: undefined },
332 logs: { protocol: "http/json", headers: undefined },
333 warnings: [],
334 },
335 {
336 name: "undecodable headers turn off every signal once, without leaking them",
337 env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz" },
338 traces: "Off",
339 logs: "Off",
340 warnings: [
341 "OTEL_EXPORTER_OTLP_HEADERS is not a list of key=value pairs with percent-encoded values, so the signals it configures are not exported",
342 ],
343 },
344 {
345 name: "a header without a value separator turns its signal off",
346 env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_HEADERS: "Authorization" },
347 traces: { protocol: "http/protobuf", headers: undefined },
348 logs: "Off",
349 warnings: [
350 "OTEL_EXPORTER_OTLP_LOGS_HEADERS is not a list of key=value pairs with percent-encoded values, so the signals it configures are not exported",
351 ],
352 },
353 {
354 name: "blank per-signal headers leave the generic ones in charge",
355 env: {
356 ...ENDPOINT,
357 OTEL_EXPORTER_OTLP_HEADERS: "api-key=shared",
358 OTEL_EXPORTER_OTLP_LOGS_HEADERS: " ",
359 },
360 traces: { protocol: "http/protobuf", headers: { "api-key": "shared" } },
361 logs: { protocol: "http/protobuf", headers: { "api-key": "shared" } },
362 warnings: [],
363 },
364 {
365 name: "generic headers every signal overrides say nothing",
366 env: {
367 OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/v1/traces",
368 OTEL_EXPORTER_OTLP_TRACES_HEADERS: "api-key=traces",
369 OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz",
370 },
371 traces: { protocol: "http/protobuf", headers: { "api-key": "traces" } },
372 logs: "Unset",
373 warnings: [],
374 },
375 {
376 name: "protocol and headers say nothing for a signal no endpoint names",
377 env: { OTEL_EXPORTER_OTLP_PROTOCOL: "grpc", OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz" },
378 traces: "Unset",
379 logs: "Unset",
380 warnings: [],
381 },
382 ])("$name", ({ env, traces, logs, warnings }) =>
383 Effect.gen(function* () {
384 const resolved = yield* load(env);
385 assert.deepStrictEqual(exportOf(resolved.traces), traces);
386 assert.deepStrictEqual(exportOf(resolved.logs), logs);
387 assert.deepStrictEqual(resolved.warnings, warnings);
388 }),
389 );
390 });
391
392 describe("resolveSignalEndpoint", () => {
393 const t3Export = {
394 protocol: "http/json",
395 headers: { "x-key": "t3" },
396 exportIntervalMs: 5_000,
397 } as const;
398 const withLogs = (logs: OtelEnvironment.OtelSignal, disabled = false) => ({
399 ...OtelEnvironment.none,
400 disabled,
401 logs,
402 });
403 const otelExport = OtelEnvironment.OtelSignal.Export({
404 url: "http://otel:4318/v1/logs",
405 protocol: "http/protobuf",
406 headers: { "x-key": "otel" },
407 });
408 it.each([
409 {
410 name: "T3CODE_OTLP_*_URL wins over an OTEL endpoint",
411 otel: withLogs(otelExport),
412 t3Url: "http://t3:4318/v1/logs",
413 expected: { url: "http://t3:4318/v1/logs", export: t3Export },
414 },
415 {
416 name: "T3CODE_OTLP_*_URL wins over a signal the OTEL variables turned off",
417 otel: withLogs(OtelEnvironment.OtelSignal.Off()),
418 t3Url: "http://t3:4318/v1/logs",
419 expected: { url: "http://t3:4318/v1/logs", export: t3Export },
420 },
421 {
422 name: "an OTEL endpoint brings its headers and protocol over the fallback",
423 otel: withLogs(otelExport),
424 t3Url: " ",
425 expected: {
426 url: "http://otel:4318/v1/logs",
427 export: {
428 protocol: "http/protobuf" as const,
429 headers: { "x-key": "otel" },
430 exportIntervalMs: 5_000,
431 },
432 },
433 },
434 {
435 name: "a signal the OTEL variables turned off does not fall through",
436 otel: withLogs(OtelEnvironment.OtelSignal.Off()),
437 t3Url: undefined,
438 expected: undefined,
439 },
440 {
441 name: "an unset signal takes the first non-blank fallback",
442 otel: withLogs(OtelEnvironment.OtelSignal.Unset()),
443 t3Url: undefined,
444 expected: { url: "http://settings:4318/v1/logs", export: t3Export },
445 },
446 {
447 name: "the kill switch wins over everything",
448 otel: withLogs(otelExport, true),
449 t3Url: "http://t3:4318/v1/logs",
450 expected: undefined,
451 },
452 ])("$name", ({ otel, t3Url, expected }) => {
453 assert.deepStrictEqual(
454 OtelEnvironment.resolveSignalEndpoint(
455 otel,
456 "logs",
457 { url: t3Url, export: t3Export },
458 "",
459 "http://settings:4318/v1/logs",
460 ),
461 expected,
462 );
463 });
464 });
465
466 it.effect("an exporter of none keeps the Settings endpoint from re-enabling its signal", () =>
467 Effect.gen(function* () {
468 const otel = yield* load({ OTEL_LOGS_EXPORTER: "none" });
469 const t3 = {
470 url: undefined,
471 export: { protocol: "http/json", headers: undefined, exportIntervalMs: 10_000 },
472 } as const;
473 assert.strictEqual(
474 OtelEnvironment.resolveSignalEndpoint(otel, "logs", t3, "http://settings:4318/v1/logs"),
475 undefined,
476 );
477 assert.strictEqual(
478 OtelEnvironment.resolveSignalEndpoint(otel, "traces", t3, "http://settings:4318/v1/traces")
479 ?.url,
480 "http://settings:4318/v1/traces",
481 );
482 }),
483 );
484
485 describe("layerResourceAttributes", () => {
486 it.effect.each([
487 { name: "a list that does not decode", raw: "team=%zz", attributes: [] },
488 { name: "encoded separators", raw: "a%2Cb=x%3Dy", attributes: ["a,b"] },
489 ])("lets the exporters' own read succeed with $name", ({ raw, attributes }) =>
490 Effect.gen(function* () {
491 const layerEnv = ConfigProvider.layer(
492 ConfigProvider.fromEnv({ env: { OTEL_RESOURCE_ATTRIBUTES: raw } }),
493 );
494 const otel = yield* OtelEnvironment.load.pipe(Effect.provide(layerEnv));
495 const resource = yield* OtlpResource.fromConfig({ serviceName: "t3" }).pipe(
496 Effect.provide(
497 Layer.provide(
498 OtelEnvironment.layerResourceAttributes(otel.resourceAttributes),
499 layerEnv,
500 ),
501 ),
502 );
503 assert.deepStrictEqual(
504 resource.attributes
505 .map((attribute) => attribute.key)
506 // Effect adds these to every resource.
507 .filter((key) => !key.startsWith("telemetry.sdk.")),
508 [...attributes, "service.name"],
509 );
510 }),
511 );
512 });
513});