authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-09-25 16:42:12-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 13:42:12-07:00
log2598ce7a5ab0b25e32e0b67b084a9ec23cd8144f
tree4630598240ee9b4af358b75f4c9f5ebfb86edb86
parent4293433eccbe6c6661acd020e584ae5d2234bf1e
signature Signed by PGP key B5690EEEBB952194

feat(desktop): honor the standard OTLP variables in the main process and WSL backends (#13641)

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

5 files changed, 254 insertions(+), 54 deletions(-)

apps/desktop/src/app/DesktopObservability.test.ts+97-8
......@@ -93,6 +93,9 @@ const collectorLayer = (requests: Array<ExportedRequest>) =>
9393 ),
9494 );
9595
96// A developer's own OTEL_* variables would otherwise pick the endpoints.
97const emptyEnv = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }));
98
9699const encodeObservabilitySettingsFile = Schema.encodeSync(
97100 Schema.fromJsonString(
98101 Schema.Struct({ observability: Schema.Record(Schema.String, Schema.String) }),
......@@ -182,7 +185,7 @@ describe("DesktopObservability", () => {
182185 assert.isFalse(yield* fileSystem.exists(logPath));
183186 }).pipe(
184187 Effect.scoped,
185 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
188 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)),
186189 ),
187190 );
188191
......@@ -260,7 +263,7 @@ describe("DesktopObservability", () => {
260263 );
261264 }).pipe(
262265 Effect.scoped,
263 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
266 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)),
264267 ),
265268 );
266269
......@@ -300,7 +303,7 @@ describe("DesktopObservability", () => {
300303 assert.equal(records.at(-1)?.annotations.details, "code=1");
301304 }).pipe(
302305 Effect.scoped,
303 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
306 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)),
304307 ),
305308 );
306309
......@@ -344,7 +347,7 @@ describe("DesktopObservability", () => {
344347 assert.isFalse(text.includes("y"));
345348 }).pipe(
346349 Effect.scoped,
347 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
350 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)),
348351 ),
349352 );
350353
......@@ -378,7 +381,7 @@ describe("DesktopObservability", () => {
378381 assert.equal(lines.length, 258);
379382 }).pipe(
380383 Effect.scoped,
381 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
384 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)),
382385 ),
383386 );
384387
......@@ -424,7 +427,93 @@ describe("DesktopObservability", () => {
424427 assert.lengthOf(record?.events ?? [], 0);
425428 }).pipe(
426429 Effect.scoped,
427 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
430 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)),
431 );
432 });
433
434 it.effect("exports to an OTEL endpoint over Settings, with its own headers and protocol", () => {
435 const requests: Array<ExportedRequest> = [];
436 return Effect.gen(function* () {
437 const fileSystem = yield* FileSystem.FileSystem;
438 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
439 prefix: "t3-desktop-observability-test-",
440 });
441 const environmentLayer = makeEnvironmentLayer(baseDir, true, {
442 T3CODE_OTLP_HEADERS: "x-scope=desktop",
443 });
444 yield* writeObservabilitySettings(environmentLayer, {
445 otlpLogsUrl: "https://settings.example.com/v1/logs",
446 });
447
448 yield* Effect.scoped(
449 Effect.logInfo("desktop otel export").pipe(
450 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
451 ),
452 );
453
454 assert.lengthOf(requests, 1);
455 const [request] = requests;
456 assert.strictEqual(request?.url, "https://collector.example.com/v1/logs");
457 assert.strictEqual(request?.headers["x-otel"], "desktop");
458 assert.strictEqual(request?.headers["x-scope"], undefined);
459 assert.strictEqual(request?.headers["content-type"], "application/json");
460 }).pipe(
461 Effect.scoped,
462 Effect.provide(
463 Layer.mergeAll(
464 NodeServices.layer,
465 collectorLayer(requests),
466 ConfigProvider.layer(
467 ConfigProvider.fromEnv({
468 env: {
469 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
470 OTEL_EXPORTER_OTLP_HEADERS: "x-otel=desktop",
471 OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json",
472 },
473 }),
474 ),
475 ),
476 ),
477 );
478 });
479
480 it.effect("exports nothing to Settings for logs an unusable OTEL endpoint claimed", () => {
481 const requests: Array<ExportedRequest> = [];
482 return Effect.gen(function* () {
483 const fileSystem = yield* FileSystem.FileSystem;
484 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
485 prefix: "t3-desktop-observability-test-",
486 });
487 const environmentLayer = makeEnvironmentLayer(baseDir, true, {
488 T3CODE_OTLP_HEADERS: "x-scope=desktop",
489 });
490 yield* writeObservabilitySettings(environmentLayer, {
491 otlpLogsUrl: "https://settings.example.com/v1/logs",
492 });
493
494 yield* Effect.scoped(
495 Effect.logInfo("desktop otel off").pipe(
496 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
497 ),
498 );
499
500 assert.lengthOf(requests, 0);
501 }).pipe(
502 Effect.scoped,
503 Effect.provide(
504 Layer.mergeAll(
505 NodeServices.layer,
506 collectorLayer(requests),
507 ConfigProvider.layer(
508 ConfigProvider.fromEnv({
509 env: {
510 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
511 OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc",
512 },
513 }),
514 ),
515 ),
516 ),
428517 );
429518 });
430519
......@@ -495,7 +584,7 @@ describe("DesktopObservability", () => {
495584 );
496585 }).pipe(
497586 Effect.scoped,
498 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
587 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)),
499588 );
500589 });
501590
......@@ -519,7 +608,7 @@ describe("DesktopObservability", () => {
519608 assert.lengthOf(requests, 0);
520609 }).pipe(
521610 Effect.scoped,
522 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
611 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)),
523612 );
524613 });
525614
apps/desktop/src/app/DesktopObservability.ts+45-19
......@@ -3,6 +3,7 @@ import {
33 makeLocalFileTracer,
44 makeTraceSink,
55 otlpSerializationLayer,
6 type SignalExport,
67} from "@t3tools/shared/observability";
78import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
89import {
......@@ -350,8 +351,10 @@ const readPersistedObservabilitySettings: Effect.Effect<
350351});
351352
352353/**
353 * Settings is read once for every signal, so the main process cannot
354 * resolve traces against one revision of the file and logs against another.
354 * Resolved as the server resolves them, with persisted Settings as the
355 * fallback. Settings is read once for every signal, so the main process
356 * cannot resolve traces against one revision of the file and logs against
357 * another.
355358 */
356359const resolveOtlpEndpoints = Effect.gen(function* () {
357360 const otel = yield* OtelEnvironment.load;
......@@ -367,10 +370,30 @@ const resolveOtlpEndpoints = Effect.gen(function* () {
367370
368371 const environment = yield* DesktopEnvironment.DesktopEnvironment;
369372 const persisted = yield* readPersistedObservabilitySettings;
373 const signalExport: SignalExport = {
374 protocol: environment.otlpProtocol,
375 headers: Option.getOrUndefined(environment.otlpHeaders),
376 exportIntervalMs: environment.otlpExportIntervalMs,
377 };
370378 return {
371 traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl,
372 metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl,
373 logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl,
379 traces: OtelEnvironment.resolveSignalEndpoint(
380 otel,
381 "traces",
382 { url: Option.getOrUndefined(environment.otlpTracesUrl), export: signalExport },
383 persisted.otlpTracesUrl,
384 ),
385 metrics: OtelEnvironment.resolveSignalEndpoint(
386 otel,
387 "metrics",
388 { url: Option.getOrUndefined(environment.otlpMetricsUrl), export: signalExport },
389 persisted.otlpMetricsUrl,
390 ),
391 logs: OtelEnvironment.resolveSignalEndpoint(
392 otel,
393 "logs",
394 { url: Option.getOrUndefined(environment.otlpLogsUrl), export: signalExport },
395 persisted.otlpLogsUrl,
396 ),
374397 warnings: otel.warnings,
375398 resourceAttributes: otel.resourceAttributes,
376399 };
......@@ -602,8 +625,6 @@ const telemetryLayer = Layer.unwrap(
602625 Effect.gen(function* () {
603626 const environment = yield* DesktopEnvironment.DesktopEnvironment;
604627 const endpoints = yield* resolveOtlpEndpoints;
605 const headers = Option.getOrUndefined(environment.otlpHeaders);
606 const serializationLayer = otlpSerializationLayer(environment.otlpProtocol);
607628 const resource = {
608629 serviceName: "desktop",
609630 attributes: {
......@@ -629,14 +650,19 @@ const telemetryLayer = Layer.unwrap(
629650 : [
630651 Logger.consolePretty(),
631652 OtlpLogger.make({
632 url: endpoints.logs,
633 exportInterval: `${environment.otlpExportIntervalMs} millis`,
634 headers,
653 url: endpoints.logs.url,
654 exportInterval: `${endpoints.logs.export.exportIntervalMs} millis`,
655 headers: endpoints.logs.export.headers,
635656 resource,
636657 }),
637658 ],
638659 { mergeWithExisting: false },
639 ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provide(serializationLayer));
660 ).pipe(
661 Layer.provide(OtlpExporter.layerFlusher),
662 Layer.provide(
663 otlpSerializationLayer(endpoints.logs?.export.protocol ?? environment.otlpProtocol),
664 ),
665 );
640666
641667 const tracerLayer = Layer.unwrap(
642668 Effect.gen(function* () {
......@@ -651,11 +677,11 @@ const telemetryLayer = Layer.unwrap(
651677 endpoints.traces === undefined
652678 ? undefined
653679 : yield* OtlpTracer.make({
654 url: endpoints.traces,
655 exportInterval: `${environment.otlpExportIntervalMs} millis`,
656 headers,
680 url: endpoints.traces.url,
681 exportInterval: `${endpoints.traces.export.exportIntervalMs} millis`,
682 headers: endpoints.traces.export.headers,
657683 resource,
658 }).pipe(Effect.provide(serializationLayer));
684 }).pipe(Effect.provide(otlpSerializationLayer(endpoints.traces.export.protocol)));
659685 const tracer = yield* makeLocalFileTracer({
660686 filePath: tracePath,
661687 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
......@@ -679,11 +705,11 @@ const telemetryLayer = Layer.unwrap(
679705 // endpoints.metrics === undefined
680706 // ? Layer.empty
681707 // : OtlpMetrics.layer({
682 // url: endpoints.metrics,
683 // exportInterval: `${environment.otlpExportIntervalMs} millis`,
684 // headers,
708 // url: endpoints.metrics.url,
709 // exportInterval: `${endpoints.metrics.export.exportIntervalMs} millis`,
710 // headers: endpoints.metrics.export.headers,
685711 // resource,
686 // }).pipe(Layer.provide(serializationLayer));
712 // }).pipe(Layer.provide(otlpSerializationLayer(endpoints.metrics.export.protocol)));
687713
688714 // Logged once the loggers above are installed, so the warnings use them.
689715 const otelWarningsLayer = Layer.effectDiscard(
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+73-3
......@@ -780,12 +780,11 @@ describe("DesktopBackendConfiguration", () => {
780780 const config = yield* configuration.resolveWsl({ port: 5050, distro: null });
781781
782782 // No settings.json exists here: the endpoints come from the desktop
783 // process's env, which a WSL child cannot inherit, so the bootstrap
784 // has to carry them or log export stays off inside the distro.
783 // environment, and the bootstrap carries them for a WSL child that
784 // lacks the variables.
785785 assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces");
786786 assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics");
787787 assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs");
788 assert.notInclude(config.env.WSLENV ?? "", "T3CODE_OTLP_LOGS_URL");
789788 }).pipe(
790789 Effect.provide(
791790 DesktopBackendConfiguration.layer.pipe(
......@@ -996,6 +995,73 @@ describe("DesktopBackendConfiguration", () => {
996995 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
997996 );
998997
998 it.effect(
999 "resolveWsl carries the standard OTLP endpoint, headers, and protocol into the distro",
1000 () =>
1001 Effect.gen(function* () {
1002 const fileSystem = yield* FileSystem.FileSystem;
1003 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
1004 prefix: "t3-desktop-backend-config-test-",
1005 });
1006
1007 const standard = {
1008 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com:4318/base?api_key=secret",
1009 OTEL_EXPORTER_OTLP_LOGS_HEADERS: "authorization=Bearer%20token",
1010 T3CODE_OTLP_TRACES_URL: "http://t3.example.com:4318/v1/traces",
1011 };
1012 const previousWslEnv = process.env.WSLENV;
1013 // A developer's own OTLP variables would be forwarded too.
1014 const ambientOtel = Object.entries(process.env).filter(
1015 ([name]) => name.startsWith("OTEL_") || name.startsWith("T3CODE_OTLP_"),
1016 );
1017 try {
1018 for (const [name] of ambientOtel) delete process.env[name];
1019 delete process.env.WSLENV;
1020 Object.assign(process.env, standard);
1021
1022 yield* Effect.gen(function* () {
1023 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
1024 const config = yield* configuration.resolveWsl({ port: 5050, distro: null });
1025
1026 assert.equal(
1027 config.env.OTEL_EXPORTER_OTLP_ENDPOINT,
1028 "https://collector.example.com:4318/base?api_key=secret",
1029 );
1030 assert.equal(
1031 config.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS,
1032 "authorization=Bearer%20token",
1033 );
1034 // Without a flag, WSL passes the values through untranslated.
1035 const wslEnv = (config.env.WSLENV ?? "").split(":");
1036 assert.include(wslEnv, "OTEL_EXPORTER_OTLP_ENDPOINT");
1037 assert.include(wslEnv, "OTEL_EXPORTER_OTLP_LOGS_HEADERS");
1038 assert.equal(config.env.T3CODE_OTLP_TRACES_URL, "http://t3.example.com:4318/v1/traces");
1039 assert.include(wslEnv, "T3CODE_OTLP_TRACES_URL");
1040 }).pipe(
1041 Effect.provide(
1042 DesktopBackendConfiguration.layer.pipe(
1043 Layer.provideMerge(serverExposureLayer),
1044 Layer.provideMerge(DesktopAppSettings.layerTest()),
1045 Layer.provideMerge(DesktopWslServerTree.layerTest()),
1046 Layer.provideMerge(
1047 DesktopWslEnvironment.layerTest({
1048 isAvailable: true,
1049 windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"),
1050 getDistroIp: () => Option.some("172.27.0.99"),
1051 }),
1052 ),
1053 Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
1054 ),
1055 ),
1056 );
1057 } finally {
1058 for (const name of Object.keys(standard)) delete process.env[name];
1059 restoreEnv("WSLENV", previousWslEnv);
1060 for (const [name, value] of ambientOtel) restoreEnv(name, value);
1061 }
1062 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1063 );
1064
9991065 it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () =>
10001066 Effect.gen(function* () {
10011067 const fileSystem = yield* FileSystem.FileSystem;
......@@ -1008,7 +1074,10 @@ describe("DesktopBackendConfiguration", () => {
10081074 const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;
10091075 const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS;
10101076 const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL;
1077 // A developer's own OTEL_* variables would be forwarded too.
1078 const ambientOtel = Object.entries(process.env).filter(([name]) => name.startsWith("OTEL_"));
10111079 try {
1080 for (const [name] of ambientOtel) delete process.env[name];
10121081 process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u";
10131082 process.env.OPENAI_API_KEY = "openai-key";
10141083 process.env.ANTHROPIC_API_KEY = "anthropic-key";
......@@ -1065,6 +1134,7 @@ describe("DesktopBackendConfiguration", () => {
10651134 restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey);
10661135 restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders);
10671136 restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol);
1137 for (const [name, value] of ambientOtel) restoreEnv(name, value);
10681138 }
10691139 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
10701140 );
apps/desktop/src/backend/DesktopBackendConfiguration.ts+27-13
......@@ -90,9 +90,8 @@ const DESKTOP_BACKEND_ENV_NAMES = [
9090] as const;
9191
9292// Env vars that the WSL backend needs but Windows process.env won't forward
93// across the wsl.exe boundary without WSLENV. The dev-server URL is handled
94// separately via a `--dev-url` CLI flag because WSLENV translation of
95// URL-shaped values (colons / slashes) is unreliable.
93// across the wsl.exe boundary without WSLENV. The dev-server URL travels as
94// the `--dev-url` CLI flag instead.
9695const WSL_FORWARDED_ENV_NAMES = [
9796 "OPENAI_API_KEY",
9897 "ANTHROPIC_API_KEY",
......@@ -101,6 +100,24 @@ const WSL_FORWARDED_ENV_NAMES = [
101100 "OTEL_SDK_DISABLED",
102101 "T3CODE_OTLP_HEADERS",
103102 "T3CODE_OTLP_PROTOCOL",
103 // Forwarded without a WSLENV flag, so the values arrive untranslated. The
104 // server prefers an OTEL endpoint over the bootstrap envelope, so the T3 URLs
105 // travel as variables to keep winning inside the distro as they do on Windows.
106 "T3CODE_OTLP_TRACES_URL",
107 "T3CODE_OTLP_METRICS_URL",
108 "T3CODE_OTLP_LOGS_URL",
109 "OTEL_EXPORTER_OTLP_ENDPOINT",
110 "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
111 "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
112 "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
113 "OTEL_EXPORTER_OTLP_HEADERS",
114 "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
115 "OTEL_EXPORTER_OTLP_METRICS_HEADERS",
116 "OTEL_EXPORTER_OTLP_LOGS_HEADERS",
117 "OTEL_EXPORTER_OTLP_PROTOCOL",
118 "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
119 "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL",
120 "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL",
104121] as const;
105122
106123const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
......@@ -221,12 +238,11 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () {
221238 };
222239});
223240
224// The bootstrap is the only channel that carries an OTLP endpoint to every
225// backend. A Windows-native child inherits the desktop process's env, but a
226// WSL child gets nothing across wsl.exe that WSLENV does not declare, and
227// WSLENV translation of URL-shaped values is unreliable, so the endpoints are
228// deliberately not forwarded that way. Env beats the persisted settings file,
229// matching the precedence resolveServerConfig and DesktopObservability apply.
241// The bootstrap carries the OTLP endpoints to every backend, including a WSL
242// child that lacks the variables. The T3 URLs also travel as variables in
243// WSL_FORWARDED_ENV_NAMES so they outrank a forwarded OTEL endpoint. Env beats
244// the persisted settings file, matching the precedence resolveServerConfig and
245// DesktopObservability apply.
230246const readBackendObservabilitySettings = Effect.gen(function* () {
231247 const environment = yield* DesktopEnvironment.DesktopEnvironment;
232248 const persisted = yield* readPersistedBackendObservabilitySettings;
......@@ -742,10 +758,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
742758 };
743759
744760 // Forward the dev-server URL as an explicit CLI flag so the WSL backend's
745 // config resolution lands in dev/ instead of userdata/. Inheriting through
746 // WSLENV is unreliable in practice (URL-shaped values with colons /
747 // slashes get translated unpredictably depending on flags), and the
748 // packaged build leaves devServerUrl as None anyway.
761 // config resolution lands in dev/ instead of userdata/. The packaged build
762 // leaves devServerUrl as None.
749763 const devUrlArgs = Option.match(environment.devServerUrl, {
750764 onNone: () => [] as ReadonlyArray<string>,
751765 onSome: (url) => ["--dev-url", url.href],
docs/operations/observability.md+12-11
......@@ -552,17 +552,18 @@ OTLP export:
552552 `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values.
553553- `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.
555The server and the desktop app also read the standard
556`OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` and generic `OTEL_EXPORTER_OTLP_ENDPOINT` (with
557`/v1/traces`, `/v1/metrics`, or `/v1/logs` appended), for a collector expecting those instead. A
558non-blank `T3CODE_OTLP_*_URL` wins over either, and a per-signal endpoint wins over the generic one
559for its signal. A blank value counts as unset. A signal with an OTEL endpoint takes its headers from
560`OTEL_EXPORTER_OTLP_HEADERS` and its protocol from `OTEL_EXPORTER_OTLP_PROTOCOL` (default
561`http/protobuf`, read case-insensitively), and a per-signal
562`OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` or `_PROTOCOL` wins over the generic one for its
563signal. `T3CODE_OTLP_HEADERS` and `T3CODE_OTLP_PROTOCOL` never apply to it. An endpoint that is not
564an `http` or `https` URL, a protocol other than `http/protobuf` or `http/json` such as `grpc`, or
565headers that are not `key=value` pairs with percent-encoded values turn that signal's export off
566with a startup warning, rather than sending it to the Settings endpoint.
566567
567568If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay
568569on stdout only.