authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-09-25 15:56:44-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 12:56:44-07:00
logfd996d15e220144ac6e66670169aa87d074ce846
treebe082e09f80c6dea04fb64ae3b9479ee73dd049e
parenta4f6078bebcb3283e53e1cea247257136ce82cc8
signature Signed by PGP key B5690EEEBB952194

feat(observability): honor the standard OTLP endpoint, headers, and protocol variables (#13492)

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>

5 files changed, 693 insertions(+), 22 deletions(-)

apps/server/src/cli/config.test.ts+142
...@@ -999,4 +999,146 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -999,4 +999,146 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
999 expect(resolved.otlpLogsUrl).toBe("http://collector.internal:4318/v1/logs");999 expect(resolved.otlpLogsUrl).toBe("http://collector.internal:4318/v1/logs");
1000 }),1000 }),
1001 );1001 );
1002
1003 const minimalWebFlags = (baseDir: string) => ({
1004 mode: Option.some("web" as const),
1005 port: Option.some(3773),
1006 host: Option.none<string>(),
1007 baseDir: Option.some(baseDir),
1008 cwd: Option.none<string>(),
1009 devUrl: Option.none<URL>(),
1010 noBrowser: Option.none<boolean>(),
1011 bootstrapFd: Option.none<number>(),
1012 autoBootstrapProjectFromCwd: Option.none<boolean>(),
1013 logWebSocketEvents: Option.none<boolean>(),
1014 tailscaleServeEnabled: Option.none<boolean>(),
1015 tailscaleServePort: Option.none<number>(),
1016 });
1017
1018 it.effect(
1019 "resolves each signal's endpoint through T3CODE_OTLP_*_URL, an OTEL endpoint, the bootstrap envelope, and persisted Settings, in that order",
1020 () =>
1021 Effect.gen(function* () {
1022 const fs = yield* FileSystem.FileSystem;
1023 const path = yield* Path.Path;
1024 const baseDir = yield* fs.makeTempDirectoryScoped({
1025 prefix: "t3-cli-config-otel-precedence-",
1026 });
1027 const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined);
1028 yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true });
1029 yield* fs.writeFileString(
1030 derivedPaths.settingsPath,
1031 // @effect-diagnostics-next-line preferSchemaOverJson:off
1032 `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`,
1033 );
1034
1035 const fd = yield* openBootstrapFd(
1036 makeDesktopBootstrap({
1037 otlpMetricsUrl: "http://bootstrap:4318/v1/metrics",
1038 // Blank, not an endpoint: it must not stand in front of Settings.
1039 otlpLogsUrl: "",
1040 }),
1041 );
1042
1043 const resolved = yield* resolveServerConfig(
1044 {
1045 ...minimalWebFlags(baseDir),
1046 mode: Option.some("desktop"),
1047 port: Option.some(4888),
1048 bootstrapFd: Option.some(fd),
1049 },
1050 Option.none(),
1051 ).pipe(
1052 Effect.provide(
1053 Layer.mergeAll(
1054 ConfigProvider.layer(
1055 ConfigProvider.fromEnv({
1056 env: {
1057 T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces",
1058 T3CODE_OTLP_HEADERS: "x-key=secret",
1059 OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://otel-traces:4318/custom",
1060 OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "http://otel-metrics:4318/custom",
1061 OTEL_EXPORTER_OTLP_HEADERS: "x-key=otel",
1062 },
1063 }),
1064 ),
1065 NetService.layer,
1066 ),
1067 ),
1068 );
1069
1070 // T3CODE_OTLP_TRACES_URL wins over the OTEL variable for the same
1071 // signal, and keeps T3 Code's own headers since T3 Code still owns it.
1072 expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces");
1073 expect(resolved.otlpTracesExport.headers).toEqual({ "x-key": "secret" });
1074 // Metrics named no T3CODE_OTLP_METRICS_URL, so the OTEL endpoint wins
1075 // over the bootstrap envelope and brings the OTEL headers and protocol.
1076 expect(resolved.otlpMetricsUrl).toBe("http://otel-metrics:4318/custom");
1077 expect(resolved.otlpMetricsExport).toEqual({
1078 ...DEFAULT_SIGNAL_EXPORT,
1079 protocol: "http/protobuf",
1080 headers: { "x-key": "otel" },
1081 });
1082 // Logs named no T3 or OTEL endpoint and a blank bootstrap value, so
1083 // Settings answers, and logs keep the shared headers since no OTEL
1084 // endpoint claimed them.
1085 expect(resolved.otlpLogsUrl).toBe("http://settings:4318/v1/logs");
1086 expect(resolved.otlpLogsExport.headers).toEqual({ "x-key": "secret" });
1087 }),
1088 );
1089
1090 it.effect(
1091 "exports nothing for a signal an OTEL endpoint claimed with a protocol or headers that do not read",
1092 () =>
1093 Effect.gen(function* () {
1094 const fs = yield* FileSystem.FileSystem;
1095 const path = yield* Path.Path;
1096 const baseDir = yield* fs.makeTempDirectoryScoped({
1097 prefix: "t3-cli-config-otel-off-",
1098 });
1099 const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined);
1100 yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true });
1101 yield* fs.writeFileString(
1102 derivedPaths.settingsPath,
1103 // @effect-diagnostics-next-line preferSchemaOverJson:off
1104 `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`,
1105 );
1106
1107 const fd = yield* openBootstrapFd(
1108 makeDesktopBootstrap({ otlpMetricsUrl: "http://bootstrap:4318/v1/metrics" }),
1109 );
1110
1111 const resolved = yield* resolveServerConfig(
1112 {
1113 ...minimalWebFlags(baseDir),
1114 mode: Option.some("desktop"),
1115 port: Option.some(4888),
1116 bootstrapFd: Option.some(fd),
1117 },
1118 Option.none(),
1119 ).pipe(
1120 Effect.provide(
1121 Layer.mergeAll(
1122 ConfigProvider.layer(
1123 ConfigProvider.fromEnv({
1124 env: {
1125 T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces",
1126 OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel:4318",
1127 OTEL_EXPORTER_OTLP_HEADERS: "x-key=%zz",
1128 },
1129 }),
1130 ),
1131 NetService.layer,
1132 ),
1133 ),
1134 );
1135
1136 // T3CODE_OTLP_TRACES_URL still wins outright.
1137 expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces");
1138 // The OTEL endpoint claimed metrics and logs, so neither the bootstrap
1139 // envelope nor Settings receives them with T3 Code's headers.
1140 expect(resolved.otlpMetricsUrl).toBeUndefined();
1141 expect(resolved.otlpLogsUrl).toBeUndefined();
1142 }),
1143 );
1002});1144});
apps/server/src/cli/config.ts+27-16
...@@ -396,6 +396,27 @@ export const resolveServerConfig = (...@@ -396,6 +396,27 @@ export const resolveServerConfig = (
396 headers: env.otlpHeaders,396 headers: env.otlpHeaders,
397 exportIntervalMs: env.otlpExportIntervalMs,397 exportIntervalMs: env.otlpExportIntervalMs,
398 };398 };
399 const traces = OtelEnvironment.resolveSignalEndpoint(
400 otel,
401 "traces",
402 { url: env.otlpTracesUrl, export: signalExport },
403 bootstrap?.otlpTracesUrl,
404 persistedObservabilitySettings.otlpTracesUrl,
405 );
406 const metrics = OtelEnvironment.resolveSignalEndpoint(
407 otel,
408 "metrics",
409 { url: env.otlpMetricsUrl, export: signalExport },
410 bootstrap?.otlpMetricsUrl,
411 persistedObservabilitySettings.otlpMetricsUrl,
412 );
413 const logs = OtelEnvironment.resolveSignalEndpoint(
414 otel,
415 "logs",
416 { url: env.otlpLogsUrl, export: signalExport },
417 bootstrap?.otlpLogsUrl,
418 persistedObservabilitySettings.otlpLogsUrl,
419 );
399420
400 const config: ServerConfig.ServerConfig["Service"] = {421 const config: ServerConfig.ServerConfig["Service"] = {
401 logLevel,422 logLevel,
...@@ -404,22 +425,12 @@ export const resolveServerConfig = (...@@ -404,22 +425,12 @@ export const resolveServerConfig = (
404 traceBatchWindowMs: env.traceBatchWindowMs,425 traceBatchWindowMs: env.traceBatchWindowMs,
405 traceMaxBytes: env.traceMaxBytes,426 traceMaxBytes: env.traceMaxBytes,
406 traceMaxFiles: env.traceMaxFiles,427 traceMaxFiles: env.traceMaxFiles,
407 otlpTracesUrl: otel.disabled428 otlpTracesUrl: traces?.url,
408 ? undefined429 otlpMetricsUrl: metrics?.url,
409 : (env.otlpTracesUrl ??430 otlpLogsUrl: logs?.url,
410 bootstrap?.otlpTracesUrl ??431 otlpTracesExport: traces?.export ?? signalExport,
411 persistedObservabilitySettings.otlpTracesUrl),432 otlpMetricsExport: metrics?.export ?? signalExport,
412 otlpMetricsUrl: otel.disabled433 otlpLogsExport: logs?.export ?? signalExport,
413 ? undefined
414 : (env.otlpMetricsUrl ??
415 bootstrap?.otlpMetricsUrl ??
416 persistedObservabilitySettings.otlpMetricsUrl),
417 otlpLogsUrl: otel.disabled
418 ? undefined
419 : (env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl),
420 otlpTracesExport: signalExport,
421 otlpMetricsExport: signalExport,
422 otlpLogsExport: signalExport,
423 otlpServiceName: env.otlpServiceName,434 otlpServiceName: env.otlpServiceName,
424 otelEnvironment: otel,435 otelEnvironment: otel,
425 mode,436 mode,
docs/operations/observability.md+12
...@@ -552,6 +552,18 @@ OTLP export:...@@ -552,6 +552,18 @@ OTLP export:
552 `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values.552 `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values.
553- `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf`553- `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf`
554554
555The server also reads the standard `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` and generic
556`OTEL_EXPORTER_OTLP_ENDPOINT` (with `/v1/traces`, `/v1/metrics`, or `/v1/logs` appended), for a
557collector expecting those instead. A non-blank `T3CODE_OTLP_*_URL` wins over either, and a
558per-signal endpoint wins over the generic one for its signal. A blank value counts as unset. A
559signal with an OTEL endpoint takes its headers from `OTEL_EXPORTER_OTLP_HEADERS` and its protocol
560from `OTEL_EXPORTER_OTLP_PROTOCOL` (default `http/protobuf`, read case-insensitively), and a
561per-signal `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` or `_PROTOCOL` wins over the generic
562one for its signal. `T3CODE_OTLP_HEADERS` and `T3CODE_OTLP_PROTOCOL` never apply to it. An endpoint
563that is not an `http` or `https` URL, a protocol other than `http/protobuf` or `http/json` such as
564`grpc`, or headers that are not `key=value` pairs with percent-encoded values turn that signal's
565export off with a startup warning, rather than sending it to the Settings endpoint.
566
555If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay567If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay
556on stdout only.568on stdout only.
557569
packages/shared/src/otelEnvironment.test.ts+290
...@@ -106,6 +106,296 @@ describe("OtelEnvironment", () => {...@@ -106,6 +106,296 @@ describe("OtelEnvironment", () => {
106 }),106 }),
107 );107 );
108108
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 ])("$name", ({ env, traces, metrics, logs, warnings }) =>
208 Effect.gen(function* () {
209 const resolved = yield* load(env);
210 assert.strictEqual(urlOf(resolved.traces), traces);
211 assert.strictEqual(urlOf(resolved.metrics), metrics);
212 assert.strictEqual(urlOf(resolved.logs), logs);
213 assert.deepStrictEqual(resolved.warnings, warnings);
214 }),
215 );
216
217 const ENDPOINT = { OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318" };
218 const exportOf = (signal: OtelEnvironment.OtelSignal): unknown =>
219 OtelEnvironment.OtelSignal.$match(signal, {
220 Export: ({ protocol, headers }) => ({ protocol, headers }),
221 Off: () => "Off",
222 Unset: () => "Unset",
223 });
224 it.effect.each([
225 {
226 name: "nothing else set takes the specification's default protocol",
227 env: ENDPOINT,
228 traces: { protocol: "http/protobuf", headers: undefined },
229 logs: { protocol: "http/protobuf", headers: undefined },
230 warnings: [],
231 },
232 {
233 name: "headers are comma-separated pairs with percent-encoded values",
234 env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS: "api-key=a%20b,tenant=t3" },
235 traces: { protocol: "http/protobuf", headers: { "api-key": "a b", tenant: "t3" } },
236 logs: { protocol: "http/protobuf", headers: { "api-key": "a b", tenant: "t3" } },
237 warnings: [],
238 },
239 {
240 name: "a per-signal protocol and headers beat the generic ones",
241 env: {
242 ...ENDPOINT,
243 OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
244 OTEL_EXPORTER_OTLP_HEADERS: "api-key=shared",
245 OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "http/protobuf",
246 OTEL_EXPORTER_OTLP_TRACES_HEADERS: "api-key=traces%20only",
247 },
248 traces: { protocol: "http/protobuf", headers: { "api-key": "traces only" } },
249 logs: { protocol: "http/json", headers: { "api-key": "shared" } },
250 warnings: [],
251 },
252 {
253 name: "an unsupported protocol turns off the signals it configures",
254 env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc" },
255 traces: { protocol: "http/protobuf", headers: undefined },
256 logs: "Off",
257 warnings: [
258 "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL is not http/protobuf or http/json, so the signals it configures are not exported",
259 ],
260 },
261 {
262 name: "a protocol reads case-insensitively",
263 env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL: "HTTP/JSON" },
264 traces: { protocol: "http/json", headers: undefined },
265 logs: { protocol: "http/json", headers: undefined },
266 warnings: [],
267 },
268 {
269 name: "undecodable headers turn off every signal once, without leaking them",
270 env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz" },
271 traces: "Off",
272 logs: "Off",
273 warnings: [
274 "OTEL_EXPORTER_OTLP_HEADERS is not a list of key=value pairs with percent-encoded values, so the signals it configures are not exported",
275 ],
276 },
277 {
278 name: "a header without a value separator turns its signal off",
279 env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_HEADERS: "Authorization" },
280 traces: { protocol: "http/protobuf", headers: undefined },
281 logs: "Off",
282 warnings: [
283 "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",
284 ],
285 },
286 {
287 name: "blank per-signal headers leave the generic ones in charge",
288 env: {
289 ...ENDPOINT,
290 OTEL_EXPORTER_OTLP_HEADERS: "api-key=shared",
291 OTEL_EXPORTER_OTLP_LOGS_HEADERS: " ",
292 },
293 traces: { protocol: "http/protobuf", headers: { "api-key": "shared" } },
294 logs: { protocol: "http/protobuf", headers: { "api-key": "shared" } },
295 warnings: [],
296 },
297 {
298 name: "generic headers every signal overrides say nothing",
299 env: {
300 OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/v1/traces",
301 OTEL_EXPORTER_OTLP_TRACES_HEADERS: "api-key=traces",
302 OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz",
303 },
304 traces: { protocol: "http/protobuf", headers: { "api-key": "traces" } },
305 logs: "Unset",
306 warnings: [],
307 },
308 {
309 name: "protocol and headers say nothing for a signal no endpoint names",
310 env: { OTEL_EXPORTER_OTLP_PROTOCOL: "grpc", OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz" },
311 traces: "Unset",
312 logs: "Unset",
313 warnings: [],
314 },
315 ])("$name", ({ env, traces, logs, warnings }) =>
316 Effect.gen(function* () {
317 const resolved = yield* load(env);
318 assert.deepStrictEqual(exportOf(resolved.traces), traces);
319 assert.deepStrictEqual(exportOf(resolved.logs), logs);
320 assert.deepStrictEqual(resolved.warnings, warnings);
321 }),
322 );
323 });
324
325 describe("resolveSignalEndpoint", () => {
326 const t3Export = {
327 protocol: "http/json",
328 headers: { "x-key": "t3" },
329 exportIntervalMs: 5_000,
330 } as const;
331 const withLogs = (logs: OtelEnvironment.OtelSignal, disabled = false) => ({
332 ...OtelEnvironment.none,
333 disabled,
334 logs,
335 });
336 const otelExport = OtelEnvironment.OtelSignal.Export({
337 url: "http://otel:4318/v1/logs",
338 protocol: "http/protobuf",
339 headers: { "x-key": "otel" },
340 });
341 it.each([
342 {
343 name: "T3CODE_OTLP_*_URL wins over an OTEL endpoint",
344 otel: withLogs(otelExport),
345 t3Url: "http://t3:4318/v1/logs",
346 expected: { url: "http://t3:4318/v1/logs", export: t3Export },
347 },
348 {
349 name: "T3CODE_OTLP_*_URL wins over a signal the OTEL variables turned off",
350 otel: withLogs(OtelEnvironment.OtelSignal.Off()),
351 t3Url: "http://t3:4318/v1/logs",
352 expected: { url: "http://t3:4318/v1/logs", export: t3Export },
353 },
354 {
355 name: "an OTEL endpoint brings its headers and protocol over the fallback",
356 otel: withLogs(otelExport),
357 t3Url: " ",
358 expected: {
359 url: "http://otel:4318/v1/logs",
360 export: {
361 protocol: "http/protobuf" as const,
362 headers: { "x-key": "otel" },
363 exportIntervalMs: 5_000,
364 },
365 },
366 },
367 {
368 name: "a signal the OTEL variables turned off does not fall through",
369 otel: withLogs(OtelEnvironment.OtelSignal.Off()),
370 t3Url: undefined,
371 expected: undefined,
372 },
373 {
374 name: "an unset signal takes the first non-blank fallback",
375 otel: withLogs(OtelEnvironment.OtelSignal.Unset()),
376 t3Url: undefined,
377 expected: { url: "http://settings:4318/v1/logs", export: t3Export },
378 },
379 {
380 name: "the kill switch wins over everything",
381 otel: withLogs(otelExport, true),
382 t3Url: "http://t3:4318/v1/logs",
383 expected: undefined,
384 },
385 ])("$name", ({ otel, t3Url, expected }) => {
386 assert.deepStrictEqual(
387 OtelEnvironment.resolveSignalEndpoint(
388 otel,
389 "logs",
390 { url: t3Url, export: t3Export },
391 "",
392 "http://settings:4318/v1/logs",
393 ),
394 expected,
395 );
396 });
397 });
398
109 describe("layerResourceAttributes", () => {399 describe("layerResourceAttributes", () => {
110 it.effect.each([400 it.effect.each([
111 { name: "a list that does not decode", raw: "team=%zz", attributes: [] },401 { name: "a list that does not decode", raw: "team=%zz", attributes: [] },
packages/shared/src/otelEnvironment.ts+222-6
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1/**1/**
2 * otelEnvironment: the OpenTelemetry kill switch, shared by the server and the2 * otelEnvironment: the OpenTelemetry kill switch and endpoint variables,
3 * desktop main process so both agree on what turns export off.3 * shared by the server and the desktop main process so both agree on what
4 * turns export off and where it goes.
4 *5 *
5 * `T3CODE_OTEL_SDK_DISABLED` is read first, so a machine that sets6 * `T3CODE_OTEL_SDK_DISABLED` is read first, so a machine that sets
6 * `OTEL_SDK_DISABLED` for everything else can still opt T3 Code back in.7 * `OTEL_SDK_DISABLED` for everything else can still opt T3 Code back in.
...@@ -9,10 +10,33 @@...@@ -9,10 +10,33 @@
9 */10 */
10import * as Config from "effect/Config";11import * as Config from "effect/Config";
11import * as ConfigProvider from "effect/ConfigProvider";12import * as ConfigProvider from "effect/ConfigProvider";
13import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";
13import * as Schema from "effect/Schema";16import * as Schema from "effect/Schema";
14import * as SchemaTransformation from "effect/SchemaTransformation";17import * as SchemaTransformation from "effect/SchemaTransformation";
1518
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, so it is
27 * exported nowhere rather than to the bootstrap or Settings collector.
28 */
29export type OtelSignal = Data.TaggedEnum<{
30 Unset: {};
31 Off: {};
32 Export: {
33 readonly url: string;
34 readonly protocol: OtlpProtocol;
35 readonly headers: Readonly<Record<string, string>> | undefined;
36 };
37}>;
38export const OtelSignal = Data.taggedEnum<OtelSignal>();
39
16export interface OtelEnvironment {40export interface OtelEnvironment {
17 /** Whether OTLP export is off, whatever endpoint is configured. */41 /** Whether OTLP export is off, whatever endpoint is configured. */
18 readonly disabled: boolean;42 readonly disabled: boolean;
...@@ -20,8 +44,17 @@ export interface OtelEnvironment {...@@ -20,8 +44,17 @@ export interface OtelEnvironment {
20 readonly warnings: ReadonlyArray<string>;44 readonly warnings: ReadonlyArray<string>;
21 /** `OTEL_RESOURCE_ATTRIBUTES`, or nothing when it could not be read. */45 /** `OTEL_RESOURCE_ATTRIBUTES`, or nothing when it could not be read. */
22 readonly resourceAttributes: Readonly<Record<string, string>>;46 readonly resourceAttributes: Readonly<Record<string, string>>;
47 readonly traces: OtelSignal;
48 readonly metrics: OtelSignal;
49 readonly logs: OtelSignal;
23}50}
2451
52/** A set but blank value reads as unset, so the source under it can answer. */
53const blankAsUnset = (value: string | undefined): string | undefined => {
54 const trimmed = value?.trim();
55 return trimmed === undefined || trimmed === "" ? undefined : trimmed;
56};
57
25interface Flag {58interface Flag {
26 /** `undefined` when the variable is unset, blank, or unreadable. */59 /** `undefined` when the variable is unset, blank, or unreadable. */
27 readonly value: boolean | undefined;60 readonly value: boolean | undefined;
...@@ -93,6 +126,121 @@ const resourceAttributes = Config.Record(...@@ -93,6 +126,121 @@ const resourceAttributes = Config.Record(
93 Config.withDefault<ResourceAttributes>({ value: {} }),126 Config.withDefault<ResourceAttributes>({ value: {} }),
94);127);
95128
129interface Setting<A> {
130 readonly value: A | undefined;
131 readonly warning?: string;
132}
133
134/**
135 * Reads one variable. Blank reads as unset, and a value `parse` rejects warns
136 * without echoing it, since these variables carry credentials.
137 */
138const readOrWarn = <A>(
139 name: string,
140 parse: (raw: string) => Option.Option<A>,
141 warning: string,
142): Config.Config<Setting<A>> =>
143 Config.String(name).pipe(
144 Config.option,
145 Config.map((option): Setting<A> => {
146 const raw = blankAsUnset(Option.getOrUndefined(option));
147 if (raw === undefined) {
148 return { value: undefined };
149 }
150 return Option.match(parse(raw), {
151 onNone: () => ({ value: undefined, warning }),
152 onSome: (value) => ({ value }),
153 });
154 }),
155 );
156
157const parseHttpUrl = (raw: string) =>
158 Option.liftThrowable((value: string) => new URL(value))(raw).pipe(
159 Option.filter((url) => url.protocol === "http:" || url.protocol === "https:"),
160 );
161
162const NOT_EXPORTED = "so the signals it configures are not exported";
163
164const endpoint = (name: string) =>
165 readOrWarn(name, parseHttpUrl, `${name} is not an http or https URL, ${NOT_EXPORTED}`);
166
167// The specification reads enum values case-insensitively.
168const protocol = (name: string) =>
169 readOrWarn(
170 name,
171 (raw) => Schema.decodeUnknownOption(OtlpProtocol)(raw.toLowerCase()),
172 `${name} is not http/protobuf or http/json, ${NOT_EXPORTED}`,
173 );
174
175const headers = (name: string) =>
176 readOrWarn(
177 name,
178 Schema.decodeUnknownOption(OtlpHeadersFromString),
179 `${name} is not a list of key=value pairs with percent-encoded values, ${NOT_EXPORTED}`,
180 );
181
182interface Settings {
183 readonly endpoint: Setting<URL>;
184 readonly protocol: Setting<OtlpProtocol>;
185 readonly headers: Setting<Readonly<Record<string, string>>>;
186}
187
188const settings = (prefix: string): Config.Config<Settings> =>
189 Config.all({
190 endpoint: endpoint(`${prefix}ENDPOINT`),
191 protocol: protocol(`${prefix}PROTOCOL`),
192 headers: headers(`${prefix}HEADERS`),
193 });
194
195/** The signal's own variable claims the signal once set, valid or not. */
196const isClaimed = (setting: Setting<unknown>) =>
197 setting.value !== undefined || setting.warning !== undefined;
198
199const claimed = <A>(own: Setting<A>, generic: Setting<A>) => (isClaimed(own) ? own : generic);
200
201/** Appends the signal's path, keeping the query an intake may take its API key in. */
202const withSignalPath = (signal: OtlpSignalName, base: URL) => {
203 const url = new URL(base);
204 const slash = url.pathname.endsWith("/") ? "" : "/";
205 url.pathname += `${slash}v1/${signal.toLowerCase()}`;
206 return url;
207};
208
209interface ResolvedSignal {
210 readonly signal: OtelSignal;
211 /** The settings this signal read, whose warnings are the signal's to report. */
212 readonly used: ReadonlyArray<Setting<unknown>>;
213}
214
215/**
216 * A signal whose endpoint, protocol, or headers do not read is not exported
217 * rather than sent somewhere, in a format, or without the credentials its
218 * collector expects.
219 */
220const signal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => {
221 const ownEndpoint = isClaimed(own.endpoint);
222 const endpoint = ownEndpoint ? own.endpoint : generic.endpoint;
223 if (endpoint.value === undefined) {
224 const signal = endpoint.warning === undefined ? OtelSignal.Unset() : OtelSignal.Off();
225 return { signal, used: [endpoint] };
226 }
227 const protocol = claimed(own.protocol, generic.protocol);
228 const headers = claimed(own.headers, generic.headers);
229 const used = [endpoint, protocol, headers];
230 if (protocol.warning !== undefined || headers.warning !== undefined) {
231 return { signal: OtelSignal.Off(), used };
232 }
233 const url = ownEndpoint ? endpoint.value : withSignalPath(name, endpoint.value);
234 return {
235 signal: OtelSignal.Export({
236 url: url.toString(),
237 protocol: protocol.value ?? "http/protobuf",
238 headers: headers.value,
239 }),
240 used,
241 };
242};
243
96export const load: Effect.Effect<OtelEnvironment> = Config.all({244export const load: Effect.Effect<OtelEnvironment> = Config.all({
97 t3: flag(245 t3: flag(
98 "T3CODE_OTEL_SDK_DISABLED",246 "T3CODE_OTEL_SDK_DISABLED",
...@@ -111,12 +259,31 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({...@@ -111,12 +259,31 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({
111 `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`,259 `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`,
112 ),260 ),
113 resource: resourceAttributes,261 resource: resourceAttributes,
262 generic: settings("OTEL_EXPORTER_OTLP_"),
263 traces: settings("OTEL_EXPORTER_OTLP_TRACES_"),
264 metrics: settings("OTEL_EXPORTER_OTLP_METRICS_"),
265 logs: settings("OTEL_EXPORTER_OTLP_LOGS_"),
114}).pipe(266}).pipe(
115 Effect.map(({ t3, spec, resource }) => {267 Effect.map(({ t3, spec, resource, generic, ...own }) => {
116 const disabled = t3.value ?? spec.value ?? false;268 const disabled = t3.value ?? spec.value ?? false;
117 const warnings = [t3.warning, spec.warning, resource.warning].filter(269 // The kill switch wins outright, so the signals say nothing once it is set.
118 (warning) => warning !== undefined,270 const signals = disabled
271 ? undefined
272 : {
273 traces: signal("TRACES", own.traces, generic),
274 metrics: signal("METRICS", own.metrics, generic),
275 logs: signal("LOGS", own.logs, generic),
276 };
277 // A generic variable read by several signals warns once.
278 const used = new Set(
279 signals === undefined ? [] : Object.values(signals).flatMap((resolved) => resolved.used),
119 );280 );
281 const warnings = [
282 t3.warning,
283 spec.warning,
284 resource.warning,
285 ...Array.from(used, (setting) => setting.warning),
286 ].filter((warning) => warning !== undefined);
120 if (disabled) {287 if (disabled) {
121 warnings.push(288 warnings.push(
122 t3.value289 t3.value
...@@ -124,12 +291,58 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({...@@ -124,12 +291,58 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({
124 : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway",291 : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway",
125 );292 );
126 }293 }
127 return { disabled, warnings, resourceAttributes: resource.value };294 return {
295 disabled,
296 warnings,
297 resourceAttributes: resource.value,
298 traces: signals?.traces.signal ?? OtelSignal.Unset(),
299 metrics: signals?.metrics.signal ?? OtelSignal.Unset(),
300 logs: signals?.logs.signal ?? OtelSignal.Unset(),
301 };
128 }),302 }),
129 // Every read above falls back instead of failing, so this cannot happen.303 // Every read above falls back instead of failing, so this cannot happen.
130 Effect.orDie,304 Effect.orDie,
131);305);
132306
307export type SignalName = "traces" | "metrics" | "logs";
308
309export interface SignalEndpoint {
310 readonly url: string;
311 readonly export: SignalExport;
312}
313
314/**
315 * Where one signal exports and how. `T3CODE_OTLP_*_URL` wins outright with
316 * T3 Code's own export, then an OTEL endpoint with its own headers and
317 * protocol, since `T3CODE_OTLP_HEADERS` was written for a different
318 * collector, then the first of `fallbackUrls` with T3 Code's own export.
319 */
320export const resolveSignalEndpoint = (
321 otel: OtelEnvironment,
322 signal: SignalName,
323 t3: { readonly url: string | undefined; readonly export: SignalExport },
324 ...fallbackUrls: ReadonlyArray<string | undefined>
325): SignalEndpoint | undefined => {
326 if (otel.disabled) {
327 return undefined;
328 }
329 const t3Url = blankAsUnset(t3.url);
330 if (t3Url !== undefined) {
331 return { url: t3Url, export: t3.export };
332 }
333 return OtelSignal.$match(otel[signal], {
334 Export: ({ url, protocol, headers }): SignalEndpoint => ({
335 url,
336 export: { protocol, headers, exportIntervalMs: t3.export.exportIntervalMs },
337 }),
338 Off: () => undefined,
339 Unset: () => {
340 const url = fallbackUrls.map(blankAsUnset).find((candidate) => candidate !== undefined);
341 return url === undefined ? undefined : { url, export: t3.export };
342 },
343 });
344};
345
133/**346/**
134 * Provide this around Effect's OTLP exporters, which read347 * Provide this around Effect's OTLP exporters, which read
135 * `OTEL_RESOURCE_ATTRIBUTES` for themselves and die when it does not decode,348 * `OTEL_RESOURCE_ATTRIBUTES` for themselves and die when it does not decode,
...@@ -154,4 +367,7 @@ export const none: OtelEnvironment = {...@@ -154,4 +367,7 @@ export const none: OtelEnvironment = {
154 disabled: false,367 disabled: false,
155 warnings: [],368 warnings: [],
156 resourceAttributes: {},369 resourceAttributes: {},
370 traces: OtelSignal.Unset(),
371 metrics: OtelSignal.Unset(),
372 logs: OtelSignal.Unset(),
157};373};