diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 34391a5995..3d61571df9 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,21 @@ function sanitizeLogValue(value: string): string { return value.replace(/\s+/g, " ").trim(); } +function readPersistedBackendObservabilitySettings(): { + readonly otlpTracesUrl: string | undefined; + readonly otlpMetricsUrl: string | undefined; +} { + try { + if (!FS.existsSync(SERVER_SETTINGS_PATH)) { + 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, otlpMetricsUrl: undefined }; + } +} + function backendChildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.T3CODE_PORT; @@ -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,12 @@ function startBackend(): void { port: backendPort, t3Home: BASE_DIR, authToken: backendAuthToken, + ...(backendObservabilitySettings.otlpTracesUrl + ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } + : {}), + ...(backendObservabilitySettings.otlpMetricsUrl + ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl } + : {}), })}\n`, ); bootstrapStream.end(); @@ -1042,21 +1068,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 +1104,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 +1125,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..038d22e8f3 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -167,6 +167,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { authToken: "bootstrap-token", 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")); @@ -202,6 +204,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { 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(), @@ -332,4 +336,67 @@ 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", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }, + })}\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.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(), + 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..09c17278a5 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,8 @@ const BootstrapEnvelopeSchema = Schema.Struct({ authToken: Schema.optional(Schema.String), 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( @@ -152,6 +155,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, otlpMetricsUrl: undefined }; + } + + const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); + return parsePersistedServerObservabilitySettings(raw); +}); + export const resolveServerConfig = ( flags: CliServerFlags, cliLogLevel: Option.Option, @@ -213,6 +227,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,8 +295,22 @@ export const resolveServerConfig = ( traceBatchWindowMs: env.traceBatchWindowMs, traceMaxBytes: env.traceMaxBytes, traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: env.otlpTracesUrl, - otlpMetricsUrl: env.otlpMetricsUrl, + otlpTracesUrl: + env.otlpTracesUrl ?? + Option.getOrUndefined( + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.otlpTracesUrl), + ), + ) ?? + persistedObservabilitySettings.otlpTracesUrl, + 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 c0aec009a0..289bc68961 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -171,6 +171,24 @@ 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 ", + 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())), + ); + it.effect("defaults blank binary paths to provider executables", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; @@ -197,6 +215,10 @@ 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", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }, providers: { codex: { binaryPath: "/opt/homebrew/bin/codex", @@ -208,6 +230,10 @@ 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", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }, 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..6633ce42a6 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -71,6 +71,12 @@ export const ClaudeSettings = Schema.Struct({ }); 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; + export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultThreadEnvMode: ThreadEnvMode.pipe( @@ -88,6 +94,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 +165,12 @@ 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), + otlpMetricsUrl: 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..0ac5e415df --- /dev/null +++ b/packages/shared/src/serverSettings.test.ts @@ -0,0 +1,53 @@ +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 ", + otlpMetricsUrl: " http://localhost:4318/v1/metrics ", + }, + }), + ).toEqual({ + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }); + }); + + it("parses lenient persisted settings JSON", () => { + expect( + parsePersistedServerObservabilitySettings( + 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 new file mode 100644 index 0000000000..e7b25606dc --- /dev/null +++ b/packages/shared/src/serverSettings.ts @@ -0,0 +1,40 @@ +import { ServerSettings } from "@t3tools/contracts"; +import { Schema } from "effect"; +import { fromLenientJson } from "./schemaJson"; + +const ServerSettingsJson = fromLenientJson(ServerSettings); + +export interface PersistedServerObservabilitySettings { + readonly otlpTracesUrl: string | undefined; + readonly otlpMetricsUrl: 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; + readonly otlpMetricsUrl?: string; + }; +}): PersistedServerObservabilitySettings { + return { + otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl), + otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl), + }; +} + +export function parsePersistedServerObservabilitySettings( + raw: string, +): PersistedServerObservabilitySettings { + try { + const decoded = Schema.decodeUnknownSync(ServerSettingsJson)(raw); + return extractPersistedServerObservabilitySettings(decoded); + } catch { + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; + } +}