1/**
2 * otelEnvironment: the OpenTelemetry kill switch, exporter, and endpoint variables,
3 * shared by the server and the desktop main process so both agree on what
4 * turns export off and where it goes.
5 *
6 * `T3CODE_OTEL_SDK_DISABLED` is read first, so a machine that sets
7 * `OTEL_SDK_DISABLED` for everything else can still opt T3 Code back in.
8 *
9 * @module otelEnvironment
10 */
11import * as Config from "effect/Config";
12import * as ConfigProvider from "effect/ConfigProvider";
13import * as Data from "effect/Data";
14import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";
16import * as Schema from "effect/Schema";
17import * as SchemaTransformation from "effect/SchemaTransformation";
18
19import { OtlpHeadersFromString, OtlpProtocol, type SignalExport } from "./observability.ts";
20
21/** The signals T3 Code exports, spelled as the variable names spell them. */
22type OtlpSignalName = "TRACES" | "METRICS" | "LOGS";
23
24/**
25 * What the OTEL variables say about one signal. `Off` is a signal they
26 * claimed with an endpoint, protocol, or headers that do not read, or turned
27 * off with `OTEL_<SIGNAL>_EXPORTER=none`, so it is exported nowhere rather
28 * than to the bootstrap or Settings collector.
29 */
30export type OtelSignal = Data.TaggedEnum<{
31 Unset: {};
32 Off: {};
33 Export: {
34 readonly url: string;
35 readonly protocol: OtlpProtocol;
36 readonly headers: Readonly<Record<string, string>> | undefined;
37 };
38}>;
39export const OtelSignal = Data.taggedEnum<OtelSignal>();
40
41export interface OtelEnvironment {
42 /** Whether OTLP export is off, whatever endpoint is configured. */
43 readonly disabled: boolean;
44 /** Messages for the caller to log once at startup. */
45 readonly warnings: ReadonlyArray<string>;
46 /** `OTEL_RESOURCE_ATTRIBUTES`, or nothing when it could not be read. */
47 readonly resourceAttributes: Readonly<Record<string, string>>;
48 readonly traces: OtelSignal;
49 readonly metrics: OtelSignal;
50 readonly logs: OtelSignal;
51}
52
53/** A set but blank value reads as unset, so the source under it can answer. */
54const blankAsUnset = (value: string | undefined): string | undefined => {
55 const trimmed = value?.trim();
56 return trimmed === undefined || trimmed === "" ? undefined : trimmed;
57};
58
59interface Flag {
60 /** `undefined` when the variable is unset, blank, or unreadable. */
61 readonly value: boolean | undefined;
62 readonly warning?: string;
63}
64
65const TrimmedLowercase = Schema.String.pipe(
66 Schema.decodeTo(
67 Schema.String,
68 SchemaTransformation.trim().pipe(
69 SchemaTransformation.composeTransformation(SchemaTransformation.toLowerCase()),
70 ),
71 ),
72);
73
74/**
75 * Reads a boolean that accepts `truthy` and `falsy`, ignoring case and padding.
76 * Any other value is ignored with a warning rather than failing startup.
77 */
78const flag = (
79 name: string,
80 truthy: ReadonlyArray<string>,
81 falsy: ReadonlyArray<string>,
82 invalid: (value: string) => string,
83) =>
84 Config.schema(
85 TrimmedLowercase.pipe(Schema.decodeTo(Schema.Literals([...truthy, ...falsy]))),
86 name,
87 ).pipe(
88 Config.map((value): Flag => ({ value: truthy.includes(value) })),
89 Config.orElse(() =>
90 Config.String(name).pipe(
91 Config.map((raw): Flag => {
92 const value = raw.trim();
93 return value === ""
94 ? { value: undefined }
95 : { value: undefined, warning: invalid(value) };
96 }),
97 ),
98 ),
99 Config.withDefault<Flag>({ value: undefined }),
100 );
101
102// `Config.Boolean`'s literals, which effect does not export on their own.
103const T3CODE_TRUE = ["true", "yes", "on", "1", "y"];
104const T3CODE_FALSE = ["false", "no", "off", "0", "n"];
105
106const RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES";
107
108interface ResourceAttributes {
109 readonly value: Readonly<Record<string, string>>;
110 readonly warning?: string;
111}
112
113// The schema Effect's OTLP exporters read this variable with.
114const resourceAttributes = Config.Record(
115 Schema.StringFromUriComponent,
116 Schema.StringFromUriComponent,
117 RESOURCE_ATTRIBUTES,
118).pipe(
119 Config.map((value): ResourceAttributes => ({ value })),
120 Config.orElse(() =>
121 Config.String(RESOURCE_ATTRIBUTES).pipe(
122 // The value is left out because attributes can carry credentials.
123 Config.map((): ResourceAttributes => ({
124 value: {},
125 warning: `${RESOURCE_ATTRIBUTES} is not a list of percent-encoded key=value pairs and was ignored`,
126 })),
127 ),
128 ),
129 Config.withDefault<ResourceAttributes>({ value: {} }),
130);
131
132interface Setting<A> {
133 readonly value: A | undefined;
134 readonly warning?: string;
135}
136
137/**
138 * Reads one variable. Blank reads as unset, and a value `parse` rejects warns
139 * without echoing it, since these variables carry credentials.
140 */
141const readOrWarn = <A>(
142 name: string,
143 parse: (raw: string) => Option.Option<A>,
144 warning: string,
145): Config.Config<Setting<A>> =>
146 Config.String(name).pipe(
147 Config.option,
148 Config.map((option): Setting<A> => {
149 const raw = blankAsUnset(Option.getOrUndefined(option));
150 if (raw === undefined) {
151 return { value: undefined };
152 }
153 return Option.match(parse(raw), {
154 onNone: () => ({ value: undefined, warning }),
155 onSome: (value) => ({ value }),
156 });
157 }),
158 );
159
160const parseHttpUrl = (raw: string) =>
161 Option.liftThrowable((value: string) => new URL(value))(raw).pipe(
162 Option.filter((url) => url.protocol === "http:" || url.protocol === "https:"),
163 );
164
165const NOT_EXPORTED = "so the signals it configures are not exported";
166
167const endpoint = (name: string) =>
168 readOrWarn(name, parseHttpUrl, `${name} is not an http or https URL, ${NOT_EXPORTED}`);
169
170// The specification reads enum values case-insensitively.
171const protocol = (name: string) =>
172 readOrWarn(
173 name,
174 (raw) => Schema.decodeUnknownOption(OtlpProtocol)(raw.toLowerCase()),
175 `${name} is not http/protobuf or http/json, ${NOT_EXPORTED}`,
176 );
177
178const headers = (name: string) =>
179 readOrWarn(
180 name,
181 Schema.decodeUnknownOption(OtlpHeadersFromString),
182 `${name} is not a list of key=value pairs with percent-encoded values, ${NOT_EXPORTED}`,
183 );
184
185type Exporter = "otlp" | "none";
186
187const EXPORTERS: ReadonlySet<string> = new Set<Exporter>(["otlp", "none"]);
188
189const isExporter = (entry: string): entry is Exporter => EXPORTERS.has(entry);
190
191/**
192 * `OTEL_<SIGNAL>_EXPORTER`, a case-insensitive list whose default is `otlp`.
193 * Entries T3 Code has no exporter for are named in a warning and dropped, and
194 * a list left with nothing to honor reads as unset, as the specification asks
195 * of any enum value an implementation does not recognize.
196 */
197const exporter = (name: string): Config.Config<Setting<Exporter>> =>
198 Config.String(name).pipe(
199 Config.option,
200 Config.map((option): Setting<Exporter> => {
201 const entries = (Option.getOrUndefined(option) ?? "")
202 .split(",")
203 .map((entry) => entry.trim().toLowerCase())
204 .filter((entry) => entry !== "");
205 const ignored = [...new Set(entries.filter((entry) => !isExporter(entry)))];
206 const known = new Set(entries.filter(isExporter));
207 const value = known.has("otlp") ? "otlp" : known.has("none") ? "none" : undefined;
208 return ignored.length === 0
209 ? { value }
210 : {
211 value,
212 warning: `${name} names ${ignored.join(", ")}, which T3 Code does not export to, so ${ignored.length === 1 ? "it was" : "they were"} ignored`,
213 };
214 }),
215 );
216
217interface Settings {
218 readonly endpoint: Setting<URL>;
219 readonly protocol: Setting<OtlpProtocol>;
220 readonly headers: Setting<Readonly<Record<string, string>>>;
221}
222
223const settings = (prefix: string): Config.Config<Settings> =>
224 Config.all({
225 endpoint: endpoint(`${prefix}ENDPOINT`),
226 protocol: protocol(`${prefix}PROTOCOL`),
227 headers: headers(`${prefix}HEADERS`),
228 });
229
230/** The signal's own variable claims the signal once set, valid or not. */
231const isClaimed = (setting: Setting<unknown>) =>
232 setting.value !== undefined || setting.warning !== undefined;
233
234const claimed = <A>(own: Setting<A>, generic: Setting<A>) => (isClaimed(own) ? own : generic);
235
236/** Appends the signal's path, keeping the query an intake may take its API key in. */
237const withSignalPath = (signal: OtlpSignalName, base: URL) => {
238 const url = new URL(base);
239 const slash = url.pathname.endsWith("/") ? "" : "/";
240 url.pathname += `${slash}v1/${signal.toLowerCase()}`;
241 return url;
242};
243
244interface ResolvedSignal {
245 readonly signal: OtelSignal;
246 /** The settings this signal read, whose warnings are the signal's to report. */
247 readonly used: ReadonlyArray<Setting<unknown>>;
248}
249
250/**
251 * A signal whose endpoint, protocol, or headers do not read is not exported
252 * rather than sent somewhere, in a format, or without the credentials its
253 * collector expects. `none` turns the signal off before any of those are read,
254 * whether or not an endpoint was named.
255 */
256const signal = (
257 name: OtlpSignalName,
258 exporter: Setting<Exporter>,
259 own: Settings,
260 generic: Settings,
261): ResolvedSignal => {
262 if (exporter.value === "none") {
263 return { signal: OtelSignal.Off(), used: [exporter] };
264 }
265 const resolved = endpointSignal(name, own, generic);
266 return { signal: resolved.signal, used: [exporter, ...resolved.used] };
267};
268
269const endpointSignal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => {
270 const ownEndpoint = isClaimed(own.endpoint);
271 const endpoint = ownEndpoint ? own.endpoint : generic.endpoint;
272 if (endpoint.value === undefined) {
273 const signal = endpoint.warning === undefined ? OtelSignal.Unset() : OtelSignal.Off();
274 return { signal, used: [endpoint] };
275 }
276 const protocol = claimed(own.protocol, generic.protocol);
277 const headers = claimed(own.headers, generic.headers);
278 const used = [endpoint, protocol, headers];
279 if (protocol.warning !== undefined || headers.warning !== undefined) {
280 return { signal: OtelSignal.Off(), used };
281 }
282 const url = ownEndpoint ? endpoint.value : withSignalPath(name, endpoint.value);
283 return {
284 signal: OtelSignal.Export({
285 url: url.toString(),
286 protocol: protocol.value ?? "http/protobuf",
287 headers: headers.value,
288 }),
289 used,
290 };
291};
292
293export const load: Effect.Effect<OtelEnvironment> = Config.all({
294 t3: flag(
295 "T3CODE_OTEL_SDK_DISABLED",
296 T3CODE_TRUE,
297 T3CODE_FALSE,
298 (value) => `T3CODE_OTEL_SDK_DISABLED=${value} is not a yes or a no and was ignored`,
299 ),
300 // The specification: a boolean it defines is true "only by the
301 // case-insensitive string `true`", implementations "MUST NOT" accept other
302 // values as true, and should warn about unrecognized ones.
303 spec: flag(
304 "OTEL_SDK_DISABLED",
305 ["true"],
306 ["false"],
307 (value) =>
308 `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`,
309 ),
310 resource: resourceAttributes,
311 generic: settings("OTEL_EXPORTER_OTLP_"),
312 traces: settings("OTEL_EXPORTER_OTLP_TRACES_"),
313 metrics: settings("OTEL_EXPORTER_OTLP_METRICS_"),
314 logs: settings("OTEL_EXPORTER_OTLP_LOGS_"),
315 exporters: Config.all({
316 traces: exporter("OTEL_TRACES_EXPORTER"),
317 metrics: exporter("OTEL_METRICS_EXPORTER"),
318 logs: exporter("OTEL_LOGS_EXPORTER"),
319 }),
320}).pipe(
321 Effect.map(({ t3, spec, resource, generic, exporters, ...own }) => {
322 const disabled = t3.value ?? spec.value ?? false;
323 // The kill switch wins outright, so the signals say nothing once it is set.
324 const signals = disabled
325 ? undefined
326 : {
327 traces: signal("TRACES", exporters.traces, own.traces, generic),
328 metrics: signal("METRICS", exporters.metrics, own.metrics, generic),
329 logs: signal("LOGS", exporters.logs, own.logs, generic),
330 };
331 // A generic variable read by several signals warns once.
332 const used = new Set(
333 signals === undefined ? [] : Object.values(signals).flatMap((resolved) => resolved.used),
334 );
335 const warnings = [
336 t3.warning,
337 spec.warning,
338 resource.warning,
339 ...Array.from(used, (setting) => setting.warning),
340 ].filter((warning) => warning !== undefined);
341 if (disabled) {
342 warnings.push(
343 t3.value
344 ? "T3CODE_OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it"
345 : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway",
346 );
347 }
348 return {
349 disabled,
350 warnings,
351 resourceAttributes: resource.value,
352 traces: signals?.traces.signal ?? OtelSignal.Unset(),
353 metrics: signals?.metrics.signal ?? OtelSignal.Unset(),
354 logs: signals?.logs.signal ?? OtelSignal.Unset(),
355 };
356 }),
357 // Every read above falls back instead of failing, so this cannot happen.
358 Effect.orDie,
359);
360
361export type SignalName = "traces" | "metrics" | "logs";
362
363export interface SignalEndpoint {
364 readonly url: string;
365 readonly export: SignalExport;
366}
367
368/**
369 * Where one signal exports and how. `T3CODE_OTLP_*_URL` wins outright with
370 * T3 Code's own export, then an OTEL endpoint with its own headers and
371 * protocol, since `T3CODE_OTLP_HEADERS` was written for a different
372 * collector, then the first of `fallbackUrls` with T3 Code's own export.
373 */
374export const resolveSignalEndpoint = (
375 otel: OtelEnvironment,
376 signal: SignalName,
377 t3: { readonly url: string | undefined; readonly export: SignalExport },
378 ...fallbackUrls: ReadonlyArray<string | undefined>
379): SignalEndpoint | undefined => {
380 if (otel.disabled) {
381 return undefined;
382 }
383 const t3Url = blankAsUnset(t3.url);
384 if (t3Url !== undefined) {
385 return { url: t3Url, export: t3.export };
386 }
387 return OtelSignal.$match(otel[signal], {
388 Export: ({ url, protocol, headers }): SignalEndpoint => ({
389 url,
390 export: { protocol, headers, exportIntervalMs: t3.export.exportIntervalMs },
391 }),
392 Off: () => undefined,
393 Unset: () => {
394 const url = fallbackUrls.map(blankAsUnset).find((candidate) => candidate !== undefined);
395 return url === undefined ? undefined : { url, export: t3.export };
396 },
397 });
398};
399
400/**
401 * Provide this around Effect's OTLP exporters, which read
402 * `OTEL_RESOURCE_ATTRIBUTES` for themselves and die when it does not decode,
403 * so they see what `load` accepted instead.
404 */
405export const layerResourceAttributes = (attributes: Readonly<Record<string, string>>) =>
406 ConfigProvider.layerAdd(
407 ConfigProvider.fromEnv({
408 env: {
409 [RESOURCE_ATTRIBUTES]: Object.entries(attributes)
410 .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
411 .join(","),
412 },
413 // Keeps an emptied list from falling through to the raw value.
414 preserveEmptyStrings: true,
415 }),
416 { asPrimary: true },
417 );
418
419/** An environment that asked for nothing, for tests and for the pairing CLI. */
420export const none: OtelEnvironment = {
421 disabled: false,
422 warnings: [],
423 resourceAttributes: {},
424 traces: OtelSignal.Unset(),
425 metrics: OtelSignal.Unset(),
426 logs: OtelSignal.Unset(),
427};