Open History to see the last commit
1import * as Config from "effect/Config";
2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as SchemaTransformation from "effect/SchemaTransformation";
8
9import type { OtlpEndpointSource } from "@t3tools/contracts";
10
11import { OtlpHeadersFromString, OtlpProtocol, type SignalExport } from "./observability.ts";
12
13type OtlpSignalName = "TRACES" | "METRICS" | "LOGS";
14
15export type OtelSignal = Data.TaggedEnum<{
16 Unset: {};
17 Off: {};
18 Export: {
19 readonly url: string;
20 readonly variable: string;
21 readonly protocol: OtlpProtocol;
22 readonly headers: Readonly<Record<string, string>> | undefined;
23 };
24}>;
25export const OtelSignal = Data.taggedEnum<OtelSignal>();
26
27export interface OtelEnvironment {
28 readonly disabled: boolean;
29 readonly warnings: ReadonlyArray<string>;
30 readonly resourceAttributes: Readonly<Record<string, string>>;
31 readonly traces: OtelSignal;
32 readonly metrics: OtelSignal;
33 readonly logs: OtelSignal;
34}
35
36const blankAsUnset = (value: string | undefined): string | undefined => {
37 const trimmed = value?.trim();
38 return trimmed === undefined || trimmed === "" ? undefined : trimmed;
39};
40
41interface Flag {
42 readonly value: boolean | undefined;
43 readonly warning?: string;
44}
45
46const TrimmedLowercase = Schema.String.pipe(
47 Schema.decodeTo(
48 Schema.String,
49 SchemaTransformation.trim().pipe(
50 SchemaTransformation.composeTransformation(SchemaTransformation.toLowerCase()),
51 ),
52 ),
53);
54
55const flag = (
56 name: string,
57 truthy: ReadonlyArray<string>,
58 falsy: ReadonlyArray<string>,
59 invalid: (value: string) => string,
60) =>
61 Config.schema(
62 TrimmedLowercase.pipe(Schema.decodeTo(Schema.Literals([...truthy, ...falsy]))),
63 name,
64 ).pipe(
65 Config.map((value): Flag => ({ value: truthy.includes(value) })),
66 Config.orElse(() =>
67 Config.String(name).pipe(
68 Config.map((raw): Flag => {
69 const value = raw.trim();
70 return value === ""
71 ? { value: undefined }
72 : { value: undefined, warning: invalid(value) };
73 }),
74 ),
75 ),
76 Config.withDefault<Flag>({ value: undefined }),
77 );
78
79// `Config.Boolean`'s literals, which effect does not export on their own.
80const T3CODE_TRUE = ["true", "yes", "on", "1", "y"];
81const T3CODE_FALSE = ["false", "no", "off", "0", "n"];
82
83const RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES";
84
85interface ResourceAttributes {
86 readonly value: Readonly<Record<string, string>>;
87 readonly warning?: string;
88}
89
90const resourceAttributes = Config.Record(
91 Schema.StringFromUriComponent,
92 Schema.StringFromUriComponent,
93 RESOURCE_ATTRIBUTES,
94).pipe(
95 Config.map((value): ResourceAttributes => ({ value })),
96 Config.orElse(() =>
97 Config.String(RESOURCE_ATTRIBUTES).pipe(
98 // The value is left out because attributes can carry credentials.
99 Config.map((): ResourceAttributes => ({
100 value: {},
101 warning: `${RESOURCE_ATTRIBUTES} is not a list of percent-encoded key=value pairs and was ignored`,
102 })),
103 ),
104 ),
105 Config.withDefault<ResourceAttributes>({ value: {} }),
106);
107
108interface Setting<A> {
109 readonly value: A | undefined;
110 readonly warning?: string;
111}
112
113const readOrWarn = <A>(
114 name: string,
115 parse: (raw: string) => Option.Option<A>,
116 warning: string,
117): Config.Config<Setting<A>> =>
118 Config.String(name).pipe(
119 Config.option,
120 Config.map((option): Setting<A> => {
121 const raw = blankAsUnset(Option.getOrUndefined(option));
122 if (raw === undefined) {
123 return { value: undefined };
124 }
125 return Option.match(parse(raw), {
126 onNone: () => ({ value: undefined, warning }),
127 onSome: (value) => ({ value }),
128 });
129 }),
130 );
131
132const parseHttpUrl = (raw: string) =>
133 Option.liftThrowable((value: string) => new URL(value))(raw).pipe(
134 Option.filter((url) => url.protocol === "http:" || url.protocol === "https:"),
135 );
136
137const NOT_EXPORTED = "so the signals it configures are not exported";
138
139const endpoint = (name: string) =>
140 readOrWarn(name, parseHttpUrl, `${name} is not an http or https URL, ${NOT_EXPORTED}`);
141const decodeOtlpProtocol = Schema.decodeUnknownOption(OtlpProtocol);
142
143// The specification reads enum values case-insensitively.
144const protocol = (name: string) =>
145 readOrWarn(
146 name,
147 (raw) => decodeOtlpProtocol(raw.toLowerCase()),
148 `${name} is not http/protobuf or http/json, ${NOT_EXPORTED}`,
149 );
150
151const headers = (name: string) =>
152 readOrWarn(
153 name,
154 Schema.decodeUnknownOption(OtlpHeadersFromString),
155 `${name} is not a list of key=value pairs with percent-encoded values, ${NOT_EXPORTED}`,
156 );
157
158type Exporter = "otlp" | "none";
159
160const EXPORTERS: ReadonlySet<string> = new Set<Exporter>(["otlp", "none"]);
161
162const isExporter = (entry: string): entry is Exporter => EXPORTERS.has(entry);
163
164const exporter = (name: string): Config.Config<Setting<Exporter>> =>
165 Config.String(name).pipe(
166 Config.option,
167 Config.map((option): Setting<Exporter> => {
168 const entries = (Option.getOrUndefined(option) ?? "")
169 .split(",")
170 .map((entry) => entry.trim().toLowerCase())
171 .filter((entry) => entry !== "");
172 const ignored = [...new Set(entries.filter((entry) => !isExporter(entry)))];
173 const known = new Set(entries.filter(isExporter));
174 const value = known.has("otlp") ? "otlp" : known.has("none") ? "none" : undefined;
175 return ignored.length === 0
176 ? { value }
177 : {
178 value,
179 warning: `${name} names ${ignored.join(", ")}, which T3 Code does not export to, so ${ignored.length === 1 ? "it was" : "they were"} ignored`,
180 };
181 }),
182 );
183
184interface Settings {
185 readonly endpoint: Setting<URL>;
186 readonly protocol: Setting<OtlpProtocol>;
187 readonly headers: Setting<Readonly<Record<string, string>>>;
188}
189
190const settings = (prefix: string): Config.Config<Settings> =>
191 Config.all({
192 endpoint: endpoint(`${prefix}ENDPOINT`),
193 protocol: protocol(`${prefix}PROTOCOL`),
194 headers: headers(`${prefix}HEADERS`),
195 });
196
197/** The signal's own variable claims the signal once set, valid or not. */
198const isClaimed = (setting: Setting<unknown>) =>
199 setting.value !== undefined || setting.warning !== undefined;
200
201const claimed = <A>(own: Setting<A>, generic: Setting<A>) => (isClaimed(own) ? own : generic);
202
203/** Appends the signal's path, keeping the query an intake may take its API key in. */
204const withSignalPath = (signal: OtlpSignalName, base: URL) => {
205 const url = new URL(base);
206 const slash = url.pathname.endsWith("/") ? "" : "/";
207 url.pathname += `${slash}v1/${signal.toLowerCase()}`;
208 return url;
209};
210
211interface ResolvedSignal {
212 readonly signal: OtelSignal;
213 readonly used: ReadonlyArray<Setting<unknown>>;
214}
215
216const signal = (
217 name: OtlpSignalName,
218 exporter: Setting<Exporter>,
219 own: Settings,
220 generic: Settings,
221): ResolvedSignal => {
222 if (exporter.value === "none") {
223 return { signal: OtelSignal.Off(), used: [exporter] };
224 }
225 const resolved = endpointSignal(name, own, generic);
226 return { signal: resolved.signal, used: [exporter, ...resolved.used] };
227};
228
229const endpointSignal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => {
230 const ownEndpoint = isClaimed(own.endpoint);
231 const endpoint = ownEndpoint ? own.endpoint : generic.endpoint;
232 if (endpoint.value === undefined) {
233 const signal = endpoint.warning === undefined ? OtelSignal.Unset() : OtelSignal.Off();
234 return { signal, used: [endpoint] };
235 }
236 const protocol = claimed(own.protocol, generic.protocol);
237 const headers = claimed(own.headers, generic.headers);
238 const used = [endpoint, protocol, headers];
239 if (protocol.warning !== undefined || headers.warning !== undefined) {
240 return { signal: OtelSignal.Off(), used };
241 }
242 const url = ownEndpoint ? endpoint.value : withSignalPath(name, endpoint.value);
243 return {
244 signal: OtelSignal.Export({
245 url: url.toString(),
246 variable: ownEndpoint ? `OTEL_EXPORTER_OTLP_${name}_ENDPOINT` : "OTEL_EXPORTER_OTLP_ENDPOINT",
247 protocol: protocol.value ?? "http/protobuf",
248 headers: headers.value,
249 }),
250 used,
251 };
252};
253
254const sdkFlags = {
255 t3: flag(
256 "T3CODE_OTEL_SDK_DISABLED",
257 T3CODE_TRUE,
258 T3CODE_FALSE,
259 (value) => `T3CODE_OTEL_SDK_DISABLED=${value} is not a yes or a no and was ignored`,
260 ),
261 // OTEL booleans are true only for case-insensitive true; warn on unrecognized values.
262 spec: flag(
263 "OTEL_SDK_DISABLED",
264 ["true"],
265 ["false"],
266 (value) =>
267 `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`,
268 ),
269};
270
271export const load: Effect.Effect<OtelEnvironment> = Config.all({
272 ...sdkFlags,
273 resource: resourceAttributes,
274 generic: settings("OTEL_EXPORTER_OTLP_"),
275 traces: settings("OTEL_EXPORTER_OTLP_TRACES_"),
276 metrics: settings("OTEL_EXPORTER_OTLP_METRICS_"),
277 logs: settings("OTEL_EXPORTER_OTLP_LOGS_"),
278 exporters: Config.all({
279 traces: exporter("OTEL_TRACES_EXPORTER"),
280 metrics: exporter("OTEL_METRICS_EXPORTER"),
281 logs: exporter("OTEL_LOGS_EXPORTER"),
282 }),
283}).pipe(
284 Effect.map(({ t3, spec, resource, generic, exporters, ...own }) => {
285 const disabled = t3.value ?? spec.value ?? false;
286 const signals = disabled
287 ? undefined
288 : {
289 traces: signal("TRACES", exporters.traces, own.traces, generic),
290 metrics: signal("METRICS", exporters.metrics, own.metrics, generic),
291 logs: signal("LOGS", exporters.logs, own.logs, generic),
292 };
293 const used = new Set(
294 signals === undefined ? [] : Object.values(signals).flatMap((resolved) => resolved.used),
295 );
296 const warnings = [
297 t3.warning,
298 spec.warning,
299 resource.warning,
300 ...Array.from(used, (setting) => setting.warning),
301 ].filter((warning) => warning !== undefined);
302 if (disabled) {
303 warnings.push(
304 t3.value
305 ? "T3CODE_OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it"
306 : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway",
307 );
308 }
309 return {
310 disabled,
311 warnings,
312 resourceAttributes: resource.value,
313 traces: signals?.traces.signal ?? OtelSignal.Unset(),
314 metrics: signals?.metrics.signal ?? OtelSignal.Unset(),
315 logs: signals?.logs.signal ?? OtelSignal.Unset(),
316 };
317 }),
318 Effect.orDie,
319);
320
321export const isRelayTracingEnabled = (env: Readonly<Record<string, string | undefined>>) =>
322 Config.all({
323 ...sdkFlags,
324 telemetry: flag("T3CODE_TELEMETRY_ENABLED", T3CODE_TRUE, T3CODE_FALSE, String),
325 }).pipe(
326 Effect.map(
327 ({ telemetry, t3, spec }) => telemetry.value !== false && !(t3.value ?? spec.value ?? false),
328 ),
329 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(env))),
330 Effect.orDie,
331 Effect.runSync,
332 );
333
334export type SignalName = "traces" | "metrics" | "logs";
335
336export interface SignalEndpoint {
337 readonly url: string;
338 readonly export: SignalExport;
339 readonly source: OtlpEndpointSource;
340 /** The environment variable that set the endpoint, when `source` is `environment`. */
341 readonly variable?: string;
342}
343
344export const resolveSignalEndpoint = (
345 otel: OtelEnvironment,
346 signal: SignalName,
347 t3: { readonly url: string | undefined; readonly export: SignalExport },
348 fallback: { readonly bootstrapUrl: string | undefined; readonly settingsUrl: string | undefined },
349): SignalEndpoint | undefined => {
350 if (otel.disabled) {
351 return undefined;
352 }
353 const t3Url = blankAsUnset(t3.url);
354 if (t3Url !== undefined) {
355 return {
356 url: t3Url,
357 export: t3.export,
358 source: "environment",
359 variable: `T3CODE_OTLP_${signal.toUpperCase()}_URL`,
360 };
361 }
362 return OtelSignal.$match(otel[signal], {
363 Export: ({ url, variable, protocol, headers }): SignalEndpoint => ({
364 url,
365 export: { protocol, headers, exportIntervalMs: t3.export.exportIntervalMs },
366 source: "environment",
367 variable,
368 }),
369 Off: () => undefined,
370 Unset: (): SignalEndpoint | undefined => {
371 const settingsUrl = blankAsUnset(fallback.settingsUrl);
372 // The desktop forwards the saved setting as its bootstrap URL, so only a differing one is
373 // the desktop's own.
374 const url = blankAsUnset(fallback.bootstrapUrl) ?? settingsUrl;
375 if (url === undefined) return undefined;
376 return { url, export: t3.export, source: url === settingsUrl ? "settings" : "bootstrap" };
377 },
378 });
379};
380
381/** Give OTLP exporters the accepted resource attributes so their own decoding cannot fail. */
382export const layerResourceAttributes = (attributes: Readonly<Record<string, string>>) =>
383 ConfigProvider.layerAdd(
384 ConfigProvider.fromEnv({
385 env: {
386 [RESOURCE_ATTRIBUTES]: Object.entries(attributes)
387 .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
388 .join(","),
389 },
390 preserveEmptyStrings: true,
391 }),
392 { asPrimary: true },
393 );
394
395export const none: OtelEnvironment = {
396 disabled: false,
397 warnings: [],
398 resourceAttributes: {},
399 traces: OtelSignal.Unset(),
400 metrics: OtelSignal.Unset(),
401 logs: OtelSignal.Unset(),
402};