authorgravatar for arne@bahlo.meArne Bahlo <arne@bahlo.me> 2026-09-14 19:46:15+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 10:46:15-07:00
log494cfac2494095c3f9b24cc16fb73721f84894a4
treebb448210709cb91d0311153529eb8b35dbac48e8
parentec5ede5e6a1f2f9d564a5834462f47d1b7e5f0ec
signature Signed by PGP key B5690EEEBB952194

Allow setting T3CODE_OTLP_HEADERS (#11218)

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

19 files changed, 220 insertions(+), 3 deletions(-)

apps/desktop/scripts/electron-launcher.mjs+1
......@@ -116,6 +116,7 @@ export function makeDevelopmentEnvironmentScript(environment) {
116116 ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH],
117117 ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL],
118118 ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS],
119 ["T3CODE_OTLP_HEADERS", environment.T3CODE_OTLP_HEADERS],
119120 ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID],
120121 ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0);
121122 return [
apps/desktop/src/app/DesktopConfig.ts+2
......@@ -1,3 +1,4 @@
1import { OtlpHeadersFromString } from "@t3tools/shared/observability";
12import * as Config from "effect/Config";
23import * as ConfigProvider from "effect/ConfigProvider";
34import * as Option from "effect/Option";
......@@ -48,6 +49,7 @@ export const DesktopConfig = Config.all({
4849 otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
4950 Config.withDefault(10_000),
5051 ),
52 otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(Config.option),
5153 appImagePath: trimmedString("APPIMAGE"),
5254 disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"),
5355 mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"),
apps/desktop/src/app/DesktopEnvironment.test.ts+8
......@@ -52,6 +52,7 @@ describe("DesktopEnvironment", () => {
5252 T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ",
5353 T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ",
5454 T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500",
55 T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3",
5556 },
5657 );
5758
......@@ -85,6 +86,13 @@ describe("DesktopEnvironment", () => {
8586 assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef"));
8687 assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces"));
8788 assert.equal(environment.otlpExportIntervalMs, 2500);
89 assert.deepEqual(
90 environment.otlpHeaders,
91 Option.some({
92 authorization: "Basic abc==",
93 "x-tenant": "t3",
94 }),
95 );
8896 }),
8997 );
9098
apps/desktop/src/app/DesktopEnvironment.ts+2
......@@ -72,6 +72,7 @@ export class DesktopEnvironment extends Context.Service<
7272 readonly commitHashOverride: Option.Option<string>;
7373 readonly otlpTracesUrl: Option.Option<string>;
7474 readonly otlpExportIntervalMs: number;
75 readonly otlpHeaders: Option.Option<Record<string, string>>;
7576 readonly branding: DesktopAppBranding;
7677 readonly displayName: string;
7778 readonly appUserModelId: string;
......@@ -225,6 +226,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
225226 commitHashOverride: config.commitHashOverride,
226227 otlpTracesUrl: config.otlpTracesUrl,
227228 otlpExportIntervalMs: config.otlpExportIntervalMs,
229 otlpHeaders: config.otlpHeaders,
228230 branding,
229231 displayName,
230232 appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () =>
apps/desktop/src/app/DesktopObservability.ts+1
......@@ -584,6 +584,7 @@ const tracerLayer = Layer.unwrap(
584584 : yield* OtlpTracer.make({
585585 url: otlpTracesUrl.value,
586586 exportInterval: `${environment.otlpExportIntervalMs} millis`,
587 headers: Option.getOrUndefined(environment.otlpHeaders),
587588 resource: {
588589 serviceName: "desktop",
589590 attributes: {
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+4-1
......@@ -853,10 +853,12 @@ describe("DesktopBackendConfiguration", () => {
853853 const previousWslEnv = process.env.WSLENV;
854854 const previousOpenAiKey = process.env.OPENAI_API_KEY;
855855 const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;
856 const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS;
856857 try {
857858 process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u";
858859 process.env.OPENAI_API_KEY = "openai-key";
859860 process.env.ANTHROPIC_API_KEY = "anthropic-key";
861 process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"';
860862
861863 yield* Effect.gen(function* () {
862864 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
......@@ -882,7 +884,7 @@ describe("DesktopBackendConfiguration", () => {
882884 // already declared, so it isn't forwarded twice.
883885 assert.equal(
884886 config.env.WSLENV,
885 "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY",
887 "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS",
886888 );
887889 }).pipe(
888890 Effect.provide(
......@@ -905,6 +907,7 @@ describe("DesktopBackendConfiguration", () => {
905907 restoreEnv("WSLENV", previousWslEnv);
906908 restoreEnv("OPENAI_API_KEY", previousOpenAiKey);
907909 restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey);
910 restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders);
908911 }
909912 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
910913 );
apps/desktop/src/backend/DesktopBackendConfiguration.ts+5-1
......@@ -91,7 +91,11 @@ const DESKTOP_BACKEND_ENV_NAMES = [
9191// forward across the wsl.exe boundary without WSLENV. The dev-server URL is
9292// handled separately via a `--dev-url` CLI flag because WSLENV translation of
9393// URL-shaped values (colons / slashes) is unreliable.
94const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const;
94const WSL_FORWARDED_ENV_NAMES = [
95 "OPENAI_API_KEY",
96 "ANTHROPIC_API_KEY",
97 "T3CODE_OTLP_HEADERS",
98] as const;
9599
96100const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
97101
apps/server/src/bin.test.ts+1
......@@ -101,6 +101,7 @@ const makeCliTestServerConfig = (baseDir: string) =>
101101 otlpMetricsUrl: undefined,
102102 otlpExportIntervalMs: 10_000,
103103 otlpServiceName: "t3-server",
104 otlpHeaders: undefined,
104105 mode: "web",
105106 port: 0,
106107 host: "127.0.0.1",
apps/server/src/cli/config.test.ts+89
......@@ -52,6 +52,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
5252 otlpMetricsUrl: undefined,
5353 otlpExportIntervalMs: 10_000,
5454 otlpServiceName: "t3-server",
55 otlpHeaders: undefined,
5556 devAllowedOrigins: [],
5657 } as const;
5758
......@@ -719,4 +720,92 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
719720 });
720721 }),
721722 );
723
724 it.effect("decodes percent-encoded OTLP headers from env", () =>
725 Effect.gen(function* () {
726 const { join } = yield* Path.Path;
727 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-headers-base");
728
729 const resolved = yield* resolveServerConfig(
730 {
731 mode: Option.some("web"),
732 port: Option.some(3773),
733 host: Option.none(),
734 baseDir: Option.some(baseDir),
735 cwd: Option.none(),
736 devUrl: Option.none(),
737 noBrowser: Option.none(),
738 bootstrapFd: Option.none(),
739 autoBootstrapProjectFromCwd: Option.none(),
740 logWebSocketEvents: Option.none(),
741 tailscaleServeEnabled: Option.none(),
742 tailscaleServePort: Option.none(),
743 },
744 Option.none(),
745 ).pipe(
746 Effect.provide(
747 Layer.mergeAll(
748 ConfigProvider.layer(
749 ConfigProvider.fromEnv({
750 env: {
751 T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3",
752 },
753 }),
754 ),
755 NetService.layer,
756 ),
757 ),
758 );
759
760 expect(resolved.otlpHeaders).toEqual({
761 authorization: "Basic abc==",
762 "x-tenant": "t3",
763 });
764 }),
765 );
766
767 it.effect("keeps whitespace-separated pairs and literal equals signs in OTLP headers", () =>
768 Effect.gen(function* () {
769 const { join } = yield* Path.Path;
770 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-headers-loose-base");
771
772 const resolved = yield* resolveServerConfig(
773 {
774 mode: Option.some("web"),
775 port: Option.some(3773),
776 host: Option.none(),
777 baseDir: Option.some(baseDir),
778 cwd: Option.none(),
779 devUrl: Option.none(),
780 noBrowser: Option.none(),
781 bootstrapFd: Option.none(),
782 autoBootstrapProjectFromCwd: Option.none(),
783 logWebSocketEvents: Option.none(),
784 tailscaleServeEnabled: Option.none(),
785 tailscaleServePort: Option.none(),
786 },
787 Option.none(),
788 ).pipe(
789 Effect.provide(
790 Layer.mergeAll(
791 ConfigProvider.layer(
792 ConfigProvider.fromEnv({
793 env: {
794 T3CODE_OTLP_HEADERS: "authorization=Bearer abc==, x-tenant=t3",
795 T3CODE_OTLP_TRACES_URL: "http://collector.internal:4318",
796 },
797 }),
798 ),
799 NetService.layer,
800 ),
801 ),
802 );
803
804 expect(resolved.otlpHeaders).toEqual({
805 authorization: "Bearer abc==",
806 "x-tenant": "t3",
807 });
808 expect(resolved.otlpTracesUrl).toBe("http://collector.internal:4318");
809 }),
810 );
722811});
apps/server/src/cli/config.ts+6
......@@ -1,4 +1,5 @@
11import * as NetService from "@t3tools/shared/Net";
2import { OtlpHeadersFromString } from "@t3tools/shared/observability";
23import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
34import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";
45import * as Config from "effect/Config";
......@@ -99,6 +100,10 @@ const EnvServerConfig = Config.all({
99100 Config.withDefault(10_000),
100101 ),
101102 otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")),
103 otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(
104 Config.option,
105 Config.map(Option.getOrUndefined),
106 ),
102107 mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe(
103108 Config.option,
104109 Config.map(Option.getOrUndefined),
......@@ -387,6 +392,7 @@ export const resolveServerConfig = (
387392 persistedObservabilitySettings.otlpMetricsUrl,
388393 otlpExportIntervalMs: env.otlpExportIntervalMs,
389394 otlpServiceName: env.otlpServiceName,
395 otlpHeaders: env.otlpHeaders,
390396 mode,
391397 port,
392398 cwd,
apps/server/src/cli/pair.ts+1
......@@ -322,6 +322,7 @@ const makePairServerConfig = Effect.fn(function* (input: {
322322 otlpMetricsUrl: undefined,
323323 otlpExportIntervalMs: 10_000,
324324 otlpServiceName: "t3-server",
325 otlpHeaders: undefined,
325326 mode: "web",
326327 port: state.port,
327328 host: state.host,
apps/server/src/config.ts+2
......@@ -73,6 +73,7 @@ export class ServerConfig extends Context.Service<
7373 readonly otlpMetricsUrl: string | undefined;
7474 readonly otlpExportIntervalMs: number;
7575 readonly otlpServiceName: string;
76 readonly otlpHeaders: Readonly<Record<string, string>> | undefined;
7677 readonly mode: RuntimeMode;
7778 readonly port: number;
7879 readonly host: string | undefined;
......@@ -197,6 +198,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
197198 otlpMetricsUrl: undefined,
198199 otlpExportIntervalMs: 10_000,
199200 otlpServiceName: "t3-server",
201 otlpHeaders: undefined,
200202 cwd,
201203 baseDir,
202204 ...derivedPaths,
apps/server/src/environment/ServerEnvironment.test.ts+1
......@@ -54,6 +54,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
5454 otlpMetricsUrl: undefined,
5555 otlpExportIntervalMs: 10_000,
5656 otlpServiceName: "t3-server",
57 otlpHeaders: undefined,
5758 cwd: process.cwd(),
5859 baseDir,
5960 mode: "web",
apps/server/src/http.ts+2
......@@ -319,6 +319,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
319319 const request = yield* HttpServerRequest.HttpServerRequest;
320320 const config = yield* ServerConfig.ServerConfig;
321321 const otlpTracesUrl = config.otlpTracesUrl;
322 const otlpHeaders = config.otlpHeaders;
322323 const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;
323324 const httpClient = yield* HttpClient.HttpClient;
324325 const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);
......@@ -343,6 +344,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
343344 return yield* httpClient
344345 .post(otlpTracesUrl, {
345346 body: HttpBody.jsonUnsafe(bodyJson),
347 headers: otlpHeaders,
346348 })
347349 .pipe(
348350 Effect.flatMap(HttpClientResponse.filterStatusOk),
apps/server/src/observability/Layers/Observability.ts+2
......@@ -49,6 +49,7 @@ export const ObservabilityLive = Layer.unwrap(
4949 : yield* OtlpTracer.make({
5050 url: config.otlpTracesUrl,
5151 exportInterval: `${config.otlpExportIntervalMs} millis`,
52 headers: config.otlpHeaders,
5253 resource: {
5354 serviceName: config.otlpServiceName,
5455 attributes: {
......@@ -80,6 +81,7 @@ export const ObservabilityLive = Layer.unwrap(
8081 : OtlpMetrics.layer({
8182 url: config.otlpMetricsUrl,
8283 exportInterval: `${config.otlpExportIntervalMs} millis`,
84 headers: config.otlpHeaders,
8385 resource: {
8486 serviceName: config.otlpServiceName,
8587 attributes: {
apps/server/src/server.test.ts+1
......@@ -576,6 +576,7 @@ const buildAppUnderTest = (options?: {
576576 otlpMetricsUrl: undefined,
577577 otlpExportIntervalMs: 10_000,
578578 otlpServiceName: "t3-server",
579 otlpHeaders: undefined,
579580 mode: "desktop",
580581 port: 0,
581582 host: "127.0.0.1",
docs/operations/observability.md+2
......@@ -528,6 +528,8 @@ OTLP export:
528528- `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint
529529- `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000`
530530- `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server`
531- `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as
532 `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values.
531533
532534If the OTLP URLs are unset, local tracing still works and metrics stay in-process only.
533535
packages/shared/src/observability.test.ts+39-1
......@@ -1,4 +1,4 @@
1import { assert, describe, it } from "@effect/vitest";
1import { assert, describe, expect, it } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import * as Arr from "effect/Array";
44import * as Cause from "effect/Cause";
......@@ -21,6 +21,7 @@ import {
2121 makeTraceSink,
2222 type TraceRecord,
2323 type TraceSinkFlushStats,
24 OtlpHeadersFromString,
2425 truncateTraceAttributes,
2526} from "./observability.ts";
2627
......@@ -459,3 +460,40 @@ describe("observability", () => {
459460 );
460461 });
461462});
463
464describe("OtlpHeadersFromString", () => {
465 const decode = Schema.decodeUnknownSync(OtlpHeadersFromString);
466
467 it.each([
468 {
469 name: "decodes percent-encoded values",
470 input: "authorization=Basic%20abc%3D%3D,x-tenant=t3",
471 expected: { authorization: "Basic abc==", "x-tenant": "t3" },
472 },
473 {
474 name: "ignores whitespace around separators",
475 input: "authorization=Basic%20abc%3D%3D, x-tenant = t3 ,",
476 expected: { authorization: "Basic abc==", "x-tenant": "t3" },
477 },
478 {
479 name: "keeps literal equals signs inside a value",
480 input: "authorization=Bearer abc==",
481 expected: { authorization: "Bearer abc==" },
482 },
483 {
484 name: "keeps an empty value",
485 input: "x-empty=",
486 expected: { "x-empty": "" },
487 },
488 ])("$name", ({ input, expected }) => {
489 expect(decode(input)).toEqual(expected);
490 });
491
492 it.each([
493 { name: "rejects a pair without a separator", input: "authorization" },
494 { name: "rejects a pair without a key", input: "=value" },
495 { name: "rejects a malformed percent-encoding", input: "authorization=%E0" },
496 ])("$name", ({ input }) => {
497 expect(() => decode(input)).toThrow();
498 });
499});
packages/shared/src/observability.ts+51
......@@ -3,6 +3,9 @@ import * as Effect from "effect/Effect";
33import type * as Exit from "effect/Exit";
44import * as ExitRuntime from "effect/Exit";
55import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as SchemaIssue from "effect/SchemaIssue";
8import * as SchemaTransformation from "effect/SchemaTransformation";
69import * as Tracer from "effect/Tracer";
710import { OtlpResource, OtlpTracer } from "effect/unstable/observability";
811
......@@ -683,3 +686,51 @@ function parseBigInt(input: string): bigint {
683686 return 0n;
684687 }
685688}
689
690/**
691 * Parses the `OTEL_EXPORTER_OTLP_HEADERS` wire format used by
692 * `T3CODE_OTLP_HEADERS`: W3C Baggage `key=value` pairs joined by commas, with
693 * percent-encoded values. Each pair splits at its first `=` so an encoded or
694 * literal `=` inside a value survives, and whitespace around the separators is
695 * ignored.
696 */
697export const OtlpHeadersFromString = Schema.String.pipe(
698 Schema.decodeTo(
699 Schema.Record(Schema.String, Schema.String),
700 SchemaTransformation.transformOrFail({
701 decode: (input) => {
702 const headers: Record<string, string> = {};
703 for (const pair of input.split(",")) {
704 if (pair.trim() === "") {
705 continue;
706 }
707 const separator = pair.indexOf("=");
708 const key = separator === -1 ? "" : pair.slice(0, separator).trim();
709 if (key === "") {
710 return Effect.fail(
711 new SchemaIssue.InvalidValue({
712 message: `Expected key=value but received ${JSON.stringify(pair.trim())}.`,
713 }),
714 );
715 }
716 try {
717 headers[key] = decodeURIComponent(pair.slice(separator + 1).trim());
718 } catch {
719 return Effect.fail(
720 new SchemaIssue.InvalidValue({
721 message: `Header ${JSON.stringify(key)} has a malformed percent-encoded value.`,
722 }),
723 );
724 }
725 }
726 return Effect.succeed(headers);
727 },
728 encode: (headers) =>
729 Effect.succeed(
730 Object.entries(headers)
731 .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
732 .join(","),
733 ),
734 }),
735 ),
736);