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) {...@@ -116,6 +116,7 @@ export function makeDevelopmentEnvironmentScript(environment) {
116 ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH],116 ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH],
117 ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL],117 ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL],
118 ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS],118 ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS],
119 ["T3CODE_OTLP_HEADERS", environment.T3CODE_OTLP_HEADERS],
119 ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID],120 ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID],
120 ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0);121 ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0);
121 return [122 return [
apps/desktop/src/app/DesktopConfig.ts+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { OtlpHeadersFromString } from "@t3tools/shared/observability";
1import * as Config from "effect/Config";2import * as Config from "effect/Config";
2import * as ConfigProvider from "effect/ConfigProvider";3import * as ConfigProvider from "effect/ConfigProvider";
3import * as Option from "effect/Option";4import * as Option from "effect/Option";
...@@ -48,6 +49,7 @@ export const DesktopConfig = Config.all({...@@ -48,6 +49,7 @@ export const DesktopConfig = Config.all({
48 otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(49 otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
49 Config.withDefault(10_000),50 Config.withDefault(10_000),
50 ),51 ),
52 otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(Config.option),
51 appImagePath: trimmedString("APPIMAGE"),53 appImagePath: trimmedString("APPIMAGE"),
52 disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"),54 disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"),
53 mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"),55 mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"),
apps/desktop/src/app/DesktopEnvironment.test.ts+8
...@@ -52,6 +52,7 @@ describe("DesktopEnvironment", () => {...@@ -52,6 +52,7 @@ describe("DesktopEnvironment", () => {
52 T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ",52 T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ",
53 T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ",53 T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ",
54 T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500",54 T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500",
55 T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3",
55 },56 },
56 );57 );
5758
...@@ -85,6 +86,13 @@ describe("DesktopEnvironment", () => {...@@ -85,6 +86,13 @@ describe("DesktopEnvironment", () => {
85 assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef"));86 assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef"));
86 assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces"));87 assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces"));
87 assert.equal(environment.otlpExportIntervalMs, 2500);88 assert.equal(environment.otlpExportIntervalMs, 2500);
89 assert.deepEqual(
90 environment.otlpHeaders,
91 Option.some({
92 authorization: "Basic abc==",
93 "x-tenant": "t3",
94 }),
95 );
88 }),96 }),
89 );97 );
9098
apps/desktop/src/app/DesktopEnvironment.ts+2
...@@ -72,6 +72,7 @@ export class DesktopEnvironment extends Context.Service<...@@ -72,6 +72,7 @@ export class DesktopEnvironment extends Context.Service<
72 readonly commitHashOverride: Option.Option<string>;72 readonly commitHashOverride: Option.Option<string>;
73 readonly otlpTracesUrl: Option.Option<string>;73 readonly otlpTracesUrl: Option.Option<string>;
74 readonly otlpExportIntervalMs: number;74 readonly otlpExportIntervalMs: number;
75 readonly otlpHeaders: Option.Option<Record<string, string>>;
75 readonly branding: DesktopAppBranding;76 readonly branding: DesktopAppBranding;
76 readonly displayName: string;77 readonly displayName: string;
77 readonly appUserModelId: string;78 readonly appUserModelId: string;
...@@ -225,6 +226,7 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -225,6 +226,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
225 commitHashOverride: config.commitHashOverride,226 commitHashOverride: config.commitHashOverride,
226 otlpTracesUrl: config.otlpTracesUrl,227 otlpTracesUrl: config.otlpTracesUrl,
227 otlpExportIntervalMs: config.otlpExportIntervalMs,228 otlpExportIntervalMs: config.otlpExportIntervalMs,
229 otlpHeaders: config.otlpHeaders,
228 branding,230 branding,
229 displayName,231 displayName,
230 appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () =>232 appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () =>
apps/desktop/src/app/DesktopObservability.ts+1
...@@ -584,6 +584,7 @@ const tracerLayer = Layer.unwrap(...@@ -584,6 +584,7 @@ const tracerLayer = Layer.unwrap(
584 : yield* OtlpTracer.make({584 : yield* OtlpTracer.make({
585 url: otlpTracesUrl.value,585 url: otlpTracesUrl.value,
586 exportInterval: `${environment.otlpExportIntervalMs} millis`,586 exportInterval: `${environment.otlpExportIntervalMs} millis`,
587 headers: Option.getOrUndefined(environment.otlpHeaders),
587 resource: {588 resource: {
588 serviceName: "desktop",589 serviceName: "desktop",
589 attributes: {590 attributes: {
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+4-1
...@@ -853,10 +853,12 @@ describe("DesktopBackendConfiguration", () => {...@@ -853,10 +853,12 @@ describe("DesktopBackendConfiguration", () => {
853 const previousWslEnv = process.env.WSLENV;853 const previousWslEnv = process.env.WSLENV;
854 const previousOpenAiKey = process.env.OPENAI_API_KEY;854 const previousOpenAiKey = process.env.OPENAI_API_KEY;
855 const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;855 const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;
856 const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS;
856 try {857 try {
857 process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u";858 process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u";
858 process.env.OPENAI_API_KEY = "openai-key";859 process.env.OPENAI_API_KEY = "openai-key";
859 process.env.ANTHROPIC_API_KEY = "anthropic-key";860 process.env.ANTHROPIC_API_KEY = "anthropic-key";
861 process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"';
860862
861 yield* Effect.gen(function* () {863 yield* Effect.gen(function* () {
862 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;864 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
...@@ -882,7 +884,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -882,7 +884,7 @@ describe("DesktopBackendConfiguration", () => {
882 // already declared, so it isn't forwarded twice.884 // already declared, so it isn't forwarded twice.
883 assert.equal(885 assert.equal(
884 config.env.WSLENV,886 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",
886 );888 );
887 }).pipe(889 }).pipe(
888 Effect.provide(890 Effect.provide(
...@@ -905,6 +907,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -905,6 +907,7 @@ describe("DesktopBackendConfiguration", () => {
905 restoreEnv("WSLENV", previousWslEnv);907 restoreEnv("WSLENV", previousWslEnv);
906 restoreEnv("OPENAI_API_KEY", previousOpenAiKey);908 restoreEnv("OPENAI_API_KEY", previousOpenAiKey);
907 restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey);909 restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey);
910 restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders);
908 }911 }
909 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),912 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
910 );913 );
apps/desktop/src/backend/DesktopBackendConfiguration.ts+5-1
...@@ -91,7 +91,11 @@ const DESKTOP_BACKEND_ENV_NAMES = [...@@ -91,7 +91,11 @@ const DESKTOP_BACKEND_ENV_NAMES = [
91// forward across the wsl.exe boundary without WSLENV. The dev-server URL is91// forward across the wsl.exe boundary without WSLENV. The dev-server URL is
92// handled separately via a `--dev-url` CLI flag because WSLENV translation of92// handled separately via a `--dev-url` CLI flag because WSLENV translation of
93// URL-shaped values (colons / slashes) is unreliable.93// 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
96const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";100const 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) =>...@@ -101,6 +101,7 @@ const makeCliTestServerConfig = (baseDir: string) =>
101 otlpMetricsUrl: undefined,101 otlpMetricsUrl: undefined,
102 otlpExportIntervalMs: 10_000,102 otlpExportIntervalMs: 10_000,
103 otlpServiceName: "t3-server",103 otlpServiceName: "t3-server",
104 otlpHeaders: undefined,
104 mode: "web",105 mode: "web",
105 port: 0,106 port: 0,
106 host: "127.0.0.1",107 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) => {...@@ -52,6 +52,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
52 otlpMetricsUrl: undefined,52 otlpMetricsUrl: undefined,
53 otlpExportIntervalMs: 10_000,53 otlpExportIntervalMs: 10_000,
54 otlpServiceName: "t3-server",54 otlpServiceName: "t3-server",
55 otlpHeaders: undefined,
55 devAllowedOrigins: [],56 devAllowedOrigins: [],
56 } as const;57 } as const;
5758
...@@ -719,4 +720,92 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -719,4 +720,92 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
719 });720 });
720 }),721 }),
721 );722 );
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 );
722});811});
apps/server/src/cli/config.ts+6
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as NetService from "@t3tools/shared/Net";1import * as NetService from "@t3tools/shared/Net";
2import { OtlpHeadersFromString } from "@t3tools/shared/observability";
2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";3import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";4import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";
4import * as Config from "effect/Config";5import * as Config from "effect/Config";
...@@ -99,6 +100,10 @@ const EnvServerConfig = Config.all({...@@ -99,6 +100,10 @@ const EnvServerConfig = Config.all({
99 Config.withDefault(10_000),100 Config.withDefault(10_000),
100 ),101 ),
101 otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")),102 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 ),
102 mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe(107 mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe(
103 Config.option,108 Config.option,
104 Config.map(Option.getOrUndefined),109 Config.map(Option.getOrUndefined),
...@@ -387,6 +392,7 @@ export const resolveServerConfig = (...@@ -387,6 +392,7 @@ export const resolveServerConfig = (
387 persistedObservabilitySettings.otlpMetricsUrl,392 persistedObservabilitySettings.otlpMetricsUrl,
388 otlpExportIntervalMs: env.otlpExportIntervalMs,393 otlpExportIntervalMs: env.otlpExportIntervalMs,
389 otlpServiceName: env.otlpServiceName,394 otlpServiceName: env.otlpServiceName,
395 otlpHeaders: env.otlpHeaders,
390 mode,396 mode,
391 port,397 port,
392 cwd,398 cwd,
apps/server/src/cli/pair.ts+1
...@@ -322,6 +322,7 @@ const makePairServerConfig = Effect.fn(function* (input: {...@@ -322,6 +322,7 @@ const makePairServerConfig = Effect.fn(function* (input: {
322 otlpMetricsUrl: undefined,322 otlpMetricsUrl: undefined,
323 otlpExportIntervalMs: 10_000,323 otlpExportIntervalMs: 10_000,
324 otlpServiceName: "t3-server",324 otlpServiceName: "t3-server",
325 otlpHeaders: undefined,
325 mode: "web",326 mode: "web",
326 port: state.port,327 port: state.port,
327 host: state.host,328 host: state.host,
apps/server/src/config.ts+2
...@@ -73,6 +73,7 @@ export class ServerConfig extends Context.Service<...@@ -73,6 +73,7 @@ export class ServerConfig extends Context.Service<
73 readonly otlpMetricsUrl: string | undefined;73 readonly otlpMetricsUrl: string | undefined;
74 readonly otlpExportIntervalMs: number;74 readonly otlpExportIntervalMs: number;
75 readonly otlpServiceName: string;75 readonly otlpServiceName: string;
76 readonly otlpHeaders: Readonly<Record<string, string>> | undefined;
76 readonly mode: RuntimeMode;77 readonly mode: RuntimeMode;
77 readonly port: number;78 readonly port: number;
78 readonly host: string | undefined;79 readonly host: string | undefined;
...@@ -197,6 +198,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (...@@ -197,6 +198,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
197 otlpMetricsUrl: undefined,198 otlpMetricsUrl: undefined,
198 otlpExportIntervalMs: 10_000,199 otlpExportIntervalMs: 10_000,
199 otlpServiceName: "t3-server",200 otlpServiceName: "t3-server",
201 otlpHeaders: undefined,
200 cwd,202 cwd,
201 baseDir,203 baseDir,
202 ...derivedPaths,204 ...derivedPaths,
apps/server/src/environment/ServerEnvironment.test.ts+1
...@@ -54,6 +54,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {...@@ -54,6 +54,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
54 otlpMetricsUrl: undefined,54 otlpMetricsUrl: undefined,
55 otlpExportIntervalMs: 10_000,55 otlpExportIntervalMs: 10_000,
56 otlpServiceName: "t3-server",56 otlpServiceName: "t3-server",
57 otlpHeaders: undefined,
57 cwd: process.cwd(),58 cwd: process.cwd(),
58 baseDir,59 baseDir,
59 mode: "web",60 mode: "web",
apps/server/src/http.ts+2
...@@ -319,6 +319,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(...@@ -319,6 +319,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
319 const request = yield* HttpServerRequest.HttpServerRequest;319 const request = yield* HttpServerRequest.HttpServerRequest;
320 const config = yield* ServerConfig.ServerConfig;320 const config = yield* ServerConfig.ServerConfig;
321 const otlpTracesUrl = config.otlpTracesUrl;321 const otlpTracesUrl = config.otlpTracesUrl;
322 const otlpHeaders = config.otlpHeaders;
322 const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;323 const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;
323 const httpClient = yield* HttpClient.HttpClient;324 const httpClient = yield* HttpClient.HttpClient;
324 const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);325 const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);
...@@ -343,6 +344,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(...@@ -343,6 +344,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
343 return yield* httpClient344 return yield* httpClient
344 .post(otlpTracesUrl, {345 .post(otlpTracesUrl, {
345 body: HttpBody.jsonUnsafe(bodyJson),346 body: HttpBody.jsonUnsafe(bodyJson),
347 headers: otlpHeaders,
346 })348 })
347 .pipe(349 .pipe(
348 Effect.flatMap(HttpClientResponse.filterStatusOk),350 Effect.flatMap(HttpClientResponse.filterStatusOk),
apps/server/src/observability/Layers/Observability.ts+2
...@@ -49,6 +49,7 @@ export const ObservabilityLive = Layer.unwrap(...@@ -49,6 +49,7 @@ export const ObservabilityLive = Layer.unwrap(
49 : yield* OtlpTracer.make({49 : yield* OtlpTracer.make({
50 url: config.otlpTracesUrl,50 url: config.otlpTracesUrl,
51 exportInterval: `${config.otlpExportIntervalMs} millis`,51 exportInterval: `${config.otlpExportIntervalMs} millis`,
52 headers: config.otlpHeaders,
52 resource: {53 resource: {
53 serviceName: config.otlpServiceName,54 serviceName: config.otlpServiceName,
54 attributes: {55 attributes: {
...@@ -80,6 +81,7 @@ export const ObservabilityLive = Layer.unwrap(...@@ -80,6 +81,7 @@ export const ObservabilityLive = Layer.unwrap(
80 : OtlpMetrics.layer({81 : OtlpMetrics.layer({
81 url: config.otlpMetricsUrl,82 url: config.otlpMetricsUrl,
82 exportInterval: `${config.otlpExportIntervalMs} millis`,83 exportInterval: `${config.otlpExportIntervalMs} millis`,
84 headers: config.otlpHeaders,
83 resource: {85 resource: {
84 serviceName: config.otlpServiceName,86 serviceName: config.otlpServiceName,
85 attributes: {87 attributes: {
apps/server/src/server.test.ts+1
...@@ -576,6 +576,7 @@ const buildAppUnderTest = (options?: {...@@ -576,6 +576,7 @@ const buildAppUnderTest = (options?: {
576 otlpMetricsUrl: undefined,576 otlpMetricsUrl: undefined,
577 otlpExportIntervalMs: 10_000,577 otlpExportIntervalMs: 10_000,
578 otlpServiceName: "t3-server",578 otlpServiceName: "t3-server",
579 otlpHeaders: undefined,
579 mode: "desktop",580 mode: "desktop",
580 port: 0,581 port: 0,
581 host: "127.0.0.1",582 host: "127.0.0.1",
docs/operations/observability.md+2
...@@ -528,6 +528,8 @@ OTLP export:...@@ -528,6 +528,8 @@ OTLP export:
528- `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint528- `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint
529- `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000`529- `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000`
530- `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server`530- `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
532If the OTLP URLs are unset, local tracing still works and metrics stay in-process only.534If 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 @@...@@ -1,4 +1,4 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Arr from "effect/Array";3import * as Arr from "effect/Array";
4import * as Cause from "effect/Cause";4import * as Cause from "effect/Cause";
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21 makeTraceSink,21 makeTraceSink,
22 type TraceRecord,22 type TraceRecord,
23 type TraceSinkFlushStats,23 type TraceSinkFlushStats,
24 OtlpHeadersFromString,
24 truncateTraceAttributes,25 truncateTraceAttributes,
25} from "./observability.ts";26} from "./observability.ts";
2627
...@@ -459,3 +460,40 @@ describe("observability", () => {...@@ -459,3 +460,40 @@ describe("observability", () => {
459 );460 );
460 });461 });
461});462});
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";...@@ -3,6 +3,9 @@ import * as Effect from "effect/Effect";
3import type * as Exit from "effect/Exit";3import type * as Exit from "effect/Exit";
4import * as ExitRuntime from "effect/Exit";4import * as ExitRuntime from "effect/Exit";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as SchemaIssue from "effect/SchemaIssue";
8import * as SchemaTransformation from "effect/SchemaTransformation";
6import * as Tracer from "effect/Tracer";9import * as Tracer from "effect/Tracer";
7import { OtlpResource, OtlpTracer } from "effect/unstable/observability";10import { OtlpResource, OtlpTracer } from "effect/unstable/observability";
811
...@@ -683,3 +686,51 @@ function parseBigInt(input: string): bigint {...@@ -683,3 +686,51 @@ function parseBigInt(input: string): bigint {
683 return 0n;686 return 0n;
684 }687 }
685}688}
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);