From 75382ba4ed6dcc3289ded4e8040613e6e4df6e3a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 20:17:45 -0700 Subject: [PATCH 1/4] Persist server tracing settings across restarts - Add shared observability settings parsing and schema support - Load persisted OTLP traces URL in the server and desktop bootstrap - Update UI defaults and tests for settings persistence Co-authored-by: codex --- apps/desktop/src/main.ts | 32 +++++++++- apps/server/src/cli-config.test.ts | 62 +++++++++++++++++++ apps/server/src/cli.ts | 25 +++++++- apps/server/src/serverSettings.test.ts | 22 +++++++ .../components/KeybindingsToast.browser.tsx | 2 + packages/contracts/src/settings.ts | 11 ++++ packages/shared/package.json | 4 ++ packages/shared/src/serverSettings.test.ts | 48 ++++++++++++++ packages/shared/src/serverSettings.ts | 37 +++++++++++ 9 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 packages/shared/src/serverSettings.test.ts create mode 100644 packages/shared/src/serverSettings.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 34391a5995..b8be042656 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -28,6 +28,7 @@ import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { syncShellEnvironment } from "./syncShellEnvironment"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; @@ -76,6 +77,7 @@ const LOG_DIR = Path.join(STATE_DIR, "logs"); const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const LOG_FILE_MAX_FILES = 10; const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); +const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json"); const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; @@ -99,8 +101,10 @@ let aboutCommitHashCache: string | null | undefined; let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; +let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); let destructiveMenuIconCache: Electron.NativeImage | null | undefined; +const expectedBackendExitChildren = new WeakSet(); const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ platform: process.platform, processArch: process.arch, @@ -121,6 +125,20 @@ function sanitizeLogValue(value: string): string { return value.replace(/\s+/g, " ").trim(); } +function readPersistedBackendObservabilitySettings(): { + readonly otlpTracesUrl: string | undefined; +} { + try { + if (!FS.existsSync(SERVER_SETTINGS_PATH)) { + return { otlpTracesUrl: undefined }; + } + return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8")); + } catch (error) { + console.warn("[desktop] failed to read persisted backend observability settings", error); + return { otlpTracesUrl: undefined }; + } +} + function backendChildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.T3CODE_PORT; @@ -129,6 +147,7 @@ function backendChildEnv(): NodeJS.ProcessEnv { delete env.T3CODE_NO_BROWSER; delete env.T3CODE_HOST; delete env.T3CODE_DESKTOP_WS_URL; + delete env.T3CODE_OTLP_TRACES_URL; return env; } @@ -988,6 +1007,7 @@ function scheduleBackendRestart(reason: string): void { function startBackend(): void { if (isQuitting || backendProcess) return; + backendObservabilitySettings = readPersistedBackendObservabilitySettings(); const backendEntry = resolveBackendEntry(); if (!FS.existsSync(backendEntry)) { scheduleBackendRestart(`missing server entry at ${backendEntry}`); @@ -1016,6 +1036,9 @@ function startBackend(): void { port: backendPort, t3Home: BASE_DIR, authToken: backendAuthToken, + ...(backendObservabilitySettings.otlpTracesUrl + ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } + : {}), })}\n`, ); bootstrapStream.end(); @@ -1042,21 +1065,26 @@ function startBackend(): void { }); child.on("error", (error) => { + const wasExpected = expectedBackendExitChildren.has(child); if (backendProcess === child) { backendProcess = null; } closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`); + if (wasExpected) { + return; + } scheduleBackendRestart(error.message); }); child.on("exit", (code, signal) => { + const wasExpected = expectedBackendExitChildren.has(child); if (backendProcess === child) { backendProcess = null; } closeBackendSession( `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`, ); - if (isQuitting) return; + if (isQuitting || wasExpected) return; const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; scheduleBackendRestart(reason); }); @@ -1073,6 +1101,7 @@ function stopBackend(): void { if (!child) return; if (child.exitCode === null && child.signalCode === null) { + expectedBackendExitChildren.add(child); child.kill("SIGTERM"); setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { @@ -1093,6 +1122,7 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { if (!child) return; const backendChild = child; if (backendChild.exitCode !== null || backendChild.signalCode !== null) return; + expectedBackendExitChildren.add(backendChild); await new Promise((resolve) => { let settled = false; diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index d3753988d9..633184ba0a 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -167,6 +167,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { authToken: "bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, + otlpTracesUrl: "http://localhost:4318/v1/traces", }); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); @@ -202,6 +203,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Info", ...defaultObservabilityConfig, + otlpTracesUrl: "http://localhost:4318/v1/traces", mode: "desktop", port: 4888, cwd: process.cwd(), @@ -332,4 +334,64 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }); }), ); + + it.effect("falls back to persisted observability settings when env vars are absent", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" }); + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + `${JSON.stringify({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + }, + })}\n`, + ); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("desktop"), + port: Option.some(4888), + host: Option.none(), + baseDir: Option.some(baseDir), + devUrl: Option.none(), + noBrowser: Option.none(), + authToken: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + NetService.layer, + ), + ), + ); + + expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); + expect(resolved).toEqual({ + logLevel: "Info", + ...defaultObservabilityConfig, + otlpTracesUrl: "http://localhost:4318/v1/traces", + mode: "desktop", + port: 4888, + cwd: process.cwd(), + baseDir, + ...derivedPaths, + host: "127.0.0.1", + staticDir: resolved.staticDir, + devUrl: undefined, + noBrowser: true, + authToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + }); + }), + ); }); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index af3202954a..727b4dae79 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -1,4 +1,5 @@ import { NetService } from "@t3tools/shared/Net"; +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect"; import { Command, Flag, GlobalFlag } from "effect/unstable/cli"; @@ -27,6 +28,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ authToken: Schema.optional(Schema.String), autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), logWebSocketEvents: Schema.optional(Schema.Boolean), + otlpTracesUrl: Schema.optional(Schema.String), }); const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( @@ -152,6 +154,17 @@ const resolveOptionPrecedence = ( ...values: ReadonlyArray> ): Option.Option => Option.firstSomeOf(values); +const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return { otlpTracesUrl: undefined }; + } + + const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); + return parsePersistedServerObservabilitySettings(raw); +}); + export const resolveServerConfig = ( flags: CliServerFlags, cliLogLevel: Option.Option, @@ -213,6 +226,9 @@ export const resolveServerConfig = ( ); const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); yield* ensureServerDirectories(derivedPaths); + const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( + derivedPaths.settingsPath, + ); const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); const noBrowser = resolveBooleanFlag( @@ -278,7 +294,14 @@ export const resolveServerConfig = ( traceBatchWindowMs: env.traceBatchWindowMs, traceMaxBytes: env.traceMaxBytes, traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: env.otlpTracesUrl, + otlpTracesUrl: + env.otlpTracesUrl ?? + Option.getOrUndefined( + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.otlpTracesUrl), + ), + ) ?? + persistedObservabilitySettings.otlpTracesUrl, otlpMetricsUrl: env.otlpMetricsUrl, otlpExportIntervalMs: env.otlpExportIntervalMs, otlpServiceName: env.otlpServiceName, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index c0aec009a0..399bc35243 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -171,6 +171,22 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("trims observability settings when updates are applied", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + observability: { + otlpTracesUrl: " http://localhost:4318/v1/traces ", + }, + }); + + assert.deepEqual(next.observability, { + otlpTracesUrl: "http://localhost:4318/v1/traces", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("defaults blank binary paths to provider executables", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; @@ -197,6 +213,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverConfig = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const next = yield* serverSettings.updateSettings({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + }, providers: { codex: { binaryPath: "/opt/homebrew/bin/codex", @@ -208,6 +227,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); assert.deepEqual(JSON.parse(raw), { + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + }, providers: { codex: { binaryPath: "/opt/homebrew/bin/codex", diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index de7d84b4ec..187ecf497a 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -1,6 +1,7 @@ import "../index.css"; import { + DEFAULT_SERVER_SETTINGS, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, @@ -63,6 +64,7 @@ function createBaseServerConfig(): ServerConfig { otlpMetricsEnabled: false, }, settings: { + ...DEFAULT_SERVER_SETTINGS, enableAssistantStreaming: false, defaultThreadEnvMode: "local" as const, textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c28b566daa..606fb606e4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -71,6 +71,11 @@ export const ClaudeSettings = Schema.Struct({ }); export type ClaudeSettings = typeof ClaudeSettings.Type; +export const ObservabilitySettings = Schema.Struct({ + otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), +}); +export type ObservabilitySettings = typeof ObservabilitySettings.Type; + export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultThreadEnvMode: ThreadEnvMode.pipe( @@ -88,6 +93,7 @@ export const ServerSettings = Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), }).pipe(Schema.withDecodingDefault(() => ({}))), + observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(() => ({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -158,6 +164,11 @@ export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), + observability: Schema.optionalKey( + Schema.Struct({ + otlpTracesUrl: Schema.optionalKey(Schema.String), + }), + ), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), diff --git a/packages/shared/package.json b/packages/shared/package.json index b35d23ef15..a80b514dd2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -40,6 +40,10 @@ "types": "./src/Struct.ts", "import": "./src/Struct.ts" }, + "./serverSettings": { + "types": "./src/serverSettings.ts", + "import": "./src/serverSettings.ts" + }, "./String": { "types": "./src/String.ts", "import": "./src/String.ts" diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts new file mode 100644 index 0000000000..50f3c5b80a --- /dev/null +++ b/packages/shared/src/serverSettings.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + extractPersistedServerObservabilitySettings, + normalizePersistedServerSettingString, + parsePersistedServerObservabilitySettings, +} from "./serverSettings"; + +describe("serverSettings helpers", () => { + it("normalizes optional persisted strings", () => { + expect(normalizePersistedServerSettingString(undefined)).toBeUndefined(); + expect(normalizePersistedServerSettingString(" ")).toBeUndefined(); + expect(normalizePersistedServerSettingString(" http://localhost:4318/v1/traces ")).toBe( + "http://localhost:4318/v1/traces", + ); + }); + + it("extracts persisted observability settings", () => { + expect( + extractPersistedServerObservabilitySettings({ + observability: { + otlpTracesUrl: " http://localhost:4318/v1/traces ", + }, + }), + ).toEqual({ + otlpTracesUrl: "http://localhost:4318/v1/traces", + }); + }); + + it("parses lenient persisted settings JSON", () => { + expect( + parsePersistedServerObservabilitySettings( + JSON.stringify({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + }, + }), + ), + ).toEqual({ + otlpTracesUrl: "http://localhost:4318/v1/traces", + }); + }); + + it("falls back cleanly when persisted settings are invalid", () => { + expect(parsePersistedServerObservabilitySettings("{")).toEqual({ + otlpTracesUrl: undefined, + }); + }); +}); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts new file mode 100644 index 0000000000..a00201b6f6 --- /dev/null +++ b/packages/shared/src/serverSettings.ts @@ -0,0 +1,37 @@ +import { ServerSettings } from "@t3tools/contracts"; +import { Schema } from "effect"; +import { fromLenientJson } from "./schemaJson"; + +const ServerSettingsJson = fromLenientJson(ServerSettings); + +export interface PersistedServerObservabilitySettings { + readonly otlpTracesUrl: string | undefined; +} + +export function normalizePersistedServerSettingString( + value: string | null | undefined, +): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +export function extractPersistedServerObservabilitySettings(input: { + readonly observability?: { + readonly otlpTracesUrl?: string; + }; +}): PersistedServerObservabilitySettings { + return { + otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl), + }; +} + +export function parsePersistedServerObservabilitySettings( + raw: string, +): PersistedServerObservabilitySettings { + try { + const decoded = Schema.decodeUnknownSync(ServerSettingsJson)(raw); + return extractPersistedServerObservabilitySettings(decoded); + } catch { + return { otlpTracesUrl: undefined }; + } +} From 448c517d3bf5579750703f3b3c25f9e94199f3fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 20:19:42 -0700 Subject: [PATCH 2/4] Add OTLP metrics settings to observability config - Persist metrics URL alongside traces URL - Thread metrics config through desktop and server bootstrap --- apps/desktop/src/main.ts | 8 ++++++-- apps/server/src/cli-config.test.ts | 5 +++++ apps/server/src/cli.ts | 10 +++++++++- apps/server/src/serverSettings.test.ts | 4 ++++ packages/contracts/src/settings.ts | 2 ++ packages/shared/src/serverSettings.test.ts | 5 +++++ packages/shared/src/serverSettings.ts | 5 ++++- 7 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b8be042656..6b47bedf89 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -127,15 +127,16 @@ function sanitizeLogValue(value: string): string { function readPersistedBackendObservabilitySettings(): { readonly otlpTracesUrl: string | undefined; + readonly otlpMetricsUrl: string | undefined; } { try { if (!FS.existsSync(SERVER_SETTINGS_PATH)) { - return { otlpTracesUrl: undefined }; + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; } return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8")); } catch (error) { console.warn("[desktop] failed to read persisted backend observability settings", error); - return { otlpTracesUrl: undefined }; + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; } } @@ -1039,6 +1040,9 @@ function startBackend(): void { ...(backendObservabilitySettings.otlpTracesUrl ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } : {}), + ...(backendObservabilitySettings.otlpMetricsUrl + ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl } + : {}), })}\n`, ); bootstrapStream.end(); diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index 633184ba0a..038d22e8f3 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -168,6 +168,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", }); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); @@ -204,6 +205,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logLevel: "Info", ...defaultObservabilityConfig, otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", mode: "desktop", port: 4888, cwd: process.cwd(), @@ -347,6 +349,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { `${JSON.stringify({ observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", }, })}\n`, ); @@ -375,10 +378,12 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ); expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); + expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics"); expect(resolved).toEqual({ logLevel: "Info", ...defaultObservabilityConfig, otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", mode: "desktop", port: 4888, cwd: process.cwd(), diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 727b4dae79..e1da94b489 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -29,6 +29,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), logWebSocketEvents: Schema.optional(Schema.Boolean), otlpTracesUrl: Schema.optional(Schema.String), + otlpMetricsUrl: Schema.optional(Schema.String), }); const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( @@ -302,7 +303,14 @@ export const resolveServerConfig = ( ), ) ?? persistedObservabilitySettings.otlpTracesUrl, - otlpMetricsUrl: env.otlpMetricsUrl, + otlpMetricsUrl: + env.otlpMetricsUrl ?? + Option.getOrUndefined( + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.otlpMetricsUrl), + ), + ) ?? + persistedObservabilitySettings.otlpMetricsUrl, otlpExportIntervalMs: env.otlpExportIntervalMs, otlpServiceName: env.otlpServiceName, mode, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 399bc35243..289bc68961 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -178,11 +178,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { const next = yield* serverSettings.updateSettings({ observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", + otlpMetricsUrl: " http://localhost:4318/v1/metrics ", }, }); assert.deepEqual(next.observability, { otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -215,6 +217,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const next = yield* serverSettings.updateSettings({ observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", }, providers: { codex: { @@ -229,6 +232,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual(JSON.parse(raw), { observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", }, providers: { codex: { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 606fb606e4..6633ce42a6 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -73,6 +73,7 @@ export type ClaudeSettings = typeof ClaudeSettings.Type; export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), + otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), }); export type ObservabilitySettings = typeof ObservabilitySettings.Type; @@ -167,6 +168,7 @@ export const ServerSettingsPatch = Schema.Struct({ observability: Schema.optionalKey( Schema.Struct({ otlpTracesUrl: Schema.optionalKey(Schema.String), + otlpMetricsUrl: Schema.optionalKey(Schema.String), }), ), providers: Schema.optionalKey( diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 50f3c5b80a..0ac5e415df 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -19,10 +19,12 @@ describe("serverSettings helpers", () => { extractPersistedServerObservabilitySettings({ observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", + otlpMetricsUrl: " http://localhost:4318/v1/metrics ", }, }), ).toEqual({ otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", }); }); @@ -32,17 +34,20 @@ describe("serverSettings helpers", () => { JSON.stringify({ observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", }, }), ), ).toEqual({ otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", }); }); it("falls back cleanly when persisted settings are invalid", () => { expect(parsePersistedServerObservabilitySettings("{")).toEqual({ otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, }); }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index a00201b6f6..e7b25606dc 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -6,6 +6,7 @@ const ServerSettingsJson = fromLenientJson(ServerSettings); export interface PersistedServerObservabilitySettings { readonly otlpTracesUrl: string | undefined; + readonly otlpMetricsUrl: string | undefined; } export function normalizePersistedServerSettingString( @@ -18,10 +19,12 @@ export function normalizePersistedServerSettingString( export function extractPersistedServerObservabilitySettings(input: { readonly observability?: { readonly otlpTracesUrl?: string; + readonly otlpMetricsUrl?: string; }; }): PersistedServerObservabilitySettings { return { otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl), + otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl), }; } @@ -32,6 +35,6 @@ export function parsePersistedServerObservabilitySettings( const decoded = Schema.decodeUnknownSync(ServerSettingsJson)(raw); return extractPersistedServerObservabilitySettings(decoded); } catch { - return { otlpTracesUrl: undefined }; + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; } } From ac1a584c0b4c8956790767d8f0b2e5313fe7d031 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 20:21:19 -0700 Subject: [PATCH 3/4] Keep metrics env out of desktop backend - Remove `T3CODE_OTLP_METRICS_URL` from backend child env - Default persisted observability settings to include `otlpMetricsUrl` --- apps/desktop/src/main.ts | 1 + apps/server/src/cli.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 6b47bedf89..b02fa8f38b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -149,6 +149,7 @@ function backendChildEnv(): NodeJS.ProcessEnv { delete env.T3CODE_HOST; delete env.T3CODE_DESKTOP_WS_URL; delete env.T3CODE_OTLP_TRACES_URL; + delete env.T3CODE_OTLP_METRICS_URL; return env; } diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index e1da94b489..09c17278a5 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -159,7 +159,7 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st const fs = yield* FileSystem.FileSystem; const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false)); if (!exists) { - return { otlpTracesUrl: undefined }; + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; } const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); From a0effd5c1b8d2b4e0b25bfa829fb5a5654a2fe06 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 20:23:44 -0700 Subject: [PATCH 4/4] keep that --- apps/desktop/src/main.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b02fa8f38b..3d61571df9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -148,8 +148,6 @@ function backendChildEnv(): NodeJS.ProcessEnv { delete env.T3CODE_NO_BROWSER; delete env.T3CODE_HOST; delete env.T3CODE_DESKTOP_WS_URL; - delete env.T3CODE_OTLP_TRACES_URL; - delete env.T3CODE_OTLP_METRICS_URL; return env; }