| author | |
| committer | |
| log | 494cfac2494095c3f9b24cc16fb73721f84894a4 |
| tree | bb448210709cb91d0311153529eb8b35dbac48e8 |
| parent | ec5ede5e6a1f2f9d564a5834462f47d1b7e5f0ec |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import { OtlpHeadersFromString } from "@t3tools/shared/observability"; | ||
| 1 | import * as Config from "effect/Config"; | 2 | import * as Config from "effect/Config"; |
| 2 | import * as ConfigProvider from "effect/ConfigProvider"; | 3 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 3 | import * as Option from "effect/Option"; | 4 | import * 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 | ); |
| 57 | 58 | ||
| ... | @@ -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 | ); |
| 90 | 98 |
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"'; | ||
| 860 | 862 | ||
| 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 is | 91 | // 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 of | 92 | // 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. |
| 94 | const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const; | 94 | const WSL_FORWARDED_ENV_NAMES = [ |
| 95 | "OPENAI_API_KEY", | ||
| 96 | "ANTHROPIC_API_KEY", | ||
| 97 | "T3CODE_OTLP_HEADERS", | ||
| 98 | ] as const; | ||
| 95 | 99 | ||
| 96 | const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; | 100 | const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; |
| 97 | 101 |
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; |
| 57 | 58 | ||
| ... | @@ -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 @@ |
| 1 | import * as NetService from "@t3tools/shared/Net"; | 1 | import * as NetService from "@t3tools/shared/Net"; |
| 2 | import { OtlpHeadersFromString } from "@t3tools/shared/observability"; | ||
| 2 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | 3 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 3 | import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; | 4 | import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; |
| 4 | import * as Config from "effect/Config"; | 5 | import * 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* httpClient | 344 | 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 endpoint | 528 | - `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. | ||
| 531 | 533 | ||
| 532 | If the OTLP URLs are unset, local tracing still works and metrics stay in-process only. | 534 | If the OTLP URLs are unset, local tracing still works and metrics stay in-process only. |
| 533 | 535 |
packages/shared/src/observability.test.ts+39-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, expect, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Arr from "effect/Array"; | 3 | import * as Arr from "effect/Array"; |
| 4 | import * as Cause from "effect/Cause"; | 4 | import * 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"; |
| 26 | 27 | ||
| ... | @@ -459,3 +460,40 @@ describe("observability", () => { | ... | @@ -459,3 +460,40 @@ describe("observability", () => { |
| 459 | ); | 460 | ); |
| 460 | }); | 461 | }); |
| 461 | }); | 462 | }); |
| 463 | |||
| 464 | describe("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"; |
| 3 | import type * as Exit from "effect/Exit"; | 3 | import type * as Exit from "effect/Exit"; |
| 4 | import * as ExitRuntime from "effect/Exit"; | 4 | import * as ExitRuntime from "effect/Exit"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as SchemaIssue from "effect/SchemaIssue"; | ||
| 8 | import * as SchemaTransformation from "effect/SchemaTransformation"; | ||
| 6 | import * as Tracer from "effect/Tracer"; | 9 | import * as Tracer from "effect/Tracer"; |
| 7 | import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; | 10 | import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; |
| 8 | 11 | ||
| ... | @@ -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 | */ | ||
| 697 | export 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 | ); |