authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-02 20:33:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-02 20:33:30-07:00
loge9ed849b53bc5644551aacb389f6c2094807371c
tree57478dad56ccd65df64747f7181687c6350d1c66
parente8f5b4ad5b28b63563fe3812998077ffb343aec8
signature Signed by PGP key B5690EEEBB952194

Persist server OTLP tracing settings across restarts (#1705)

Co-authored-by: codex <codex@users.noreply.github.com>

9 files changed, 272 insertions(+), 3 deletions(-)

apps/desktop/src/main.ts+34-1
......@@ -28,6 +28,7 @@ import { autoUpdater } from "electron-updater";
2828import type { ContextMenuItem } from "@t3tools/contracts";
2929import { NetService } from "@t3tools/shared/Net";
3030import { RotatingFileSink } from "@t3tools/shared/logging";
31import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3132import { showDesktopConfirmDialog } from "./confirmDialog";
3233import { syncShellEnvironment } from "./syncShellEnvironment";
3334import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
......@@ -76,6 +77,7 @@ const LOG_DIR = Path.join(STATE_DIR, "logs");
7677const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
7778const LOG_FILE_MAX_FILES = 10;
7879const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
80const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");
7981const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
8082const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
8183const DESKTOP_UPDATE_CHANNEL = "latest";
......@@ -99,8 +101,10 @@ let aboutCommitHashCache: string | null | undefined;
99101let desktopLogSink: RotatingFileSink | null = null;
100102let backendLogSink: RotatingFileSink | null = null;
101103let restoreStdIoCapture: (() => void) | null = null;
104let backendObservabilitySettings = readPersistedBackendObservabilitySettings();
102105
103106let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
107const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();
104108const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
105109 platform: process.platform,
106110 processArch: process.arch,
......@@ -121,6 +125,21 @@ function sanitizeLogValue(value: string): string {
121125 return value.replace(/\s+/g, " ").trim();
122126}
123127
128function readPersistedBackendObservabilitySettings(): {
129 readonly otlpTracesUrl: string | undefined;
130 readonly otlpMetricsUrl: string | undefined;
131} {
132 try {
133 if (!FS.existsSync(SERVER_SETTINGS_PATH)) {
134 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
135 }
136 return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8"));
137 } catch (error) {
138 console.warn("[desktop] failed to read persisted backend observability settings", error);
139 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
140 }
141}
142
124143function backendChildEnv(): NodeJS.ProcessEnv {
125144 const env = { ...process.env };
126145 delete env.T3CODE_PORT;
......@@ -988,6 +1007,7 @@ function scheduleBackendRestart(reason: string): void {
9881007function startBackend(): void {
9891008 if (isQuitting || backendProcess) return;
9901009
1010 backendObservabilitySettings = readPersistedBackendObservabilitySettings();
9911011 const backendEntry = resolveBackendEntry();
9921012 if (!FS.existsSync(backendEntry)) {
9931013 scheduleBackendRestart(`missing server entry at ${backendEntry}`);
......@@ -1016,6 +1036,12 @@ function startBackend(): void {
10161036 port: backendPort,
10171037 t3Home: BASE_DIR,
10181038 authToken: backendAuthToken,
1039 ...(backendObservabilitySettings.otlpTracesUrl
1040 ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl }
1041 : {}),
1042 ...(backendObservabilitySettings.otlpMetricsUrl
1043 ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl }
1044 : {}),
10191045 })}\n`,
10201046 );
10211047 bootstrapStream.end();
......@@ -1042,21 +1068,26 @@ function startBackend(): void {
10421068 });
10431069
10441070 child.on("error", (error) => {
1071 const wasExpected = expectedBackendExitChildren.has(child);
10451072 if (backendProcess === child) {
10461073 backendProcess = null;
10471074 }
10481075 closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`);
1076 if (wasExpected) {
1077 return;
1078 }
10491079 scheduleBackendRestart(error.message);
10501080 });
10511081
10521082 child.on("exit", (code, signal) => {
1083 const wasExpected = expectedBackendExitChildren.has(child);
10531084 if (backendProcess === child) {
10541085 backendProcess = null;
10551086 }
10561087 closeBackendSession(
10571088 `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`,
10581089 );
1059 if (isQuitting) return;
1090 if (isQuitting || wasExpected) return;
10601091 const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
10611092 scheduleBackendRestart(reason);
10621093 });
......@@ -1073,6 +1104,7 @@ function stopBackend(): void {
10731104 if (!child) return;
10741105
10751106 if (child.exitCode === null && child.signalCode === null) {
1107 expectedBackendExitChildren.add(child);
10761108 child.kill("SIGTERM");
10771109 setTimeout(() => {
10781110 if (child.exitCode === null && child.signalCode === null) {
......@@ -1093,6 +1125,7 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
10931125 if (!child) return;
10941126 const backendChild = child;
10951127 if (backendChild.exitCode !== null || backendChild.signalCode !== null) return;
1128 expectedBackendExitChildren.add(backendChild);
10961129
10971130 await new Promise<void>((resolve) => {
10981131 let settled = false;
apps/server/src/cli-config.test.ts+67
......@@ -167,6 +167,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
167167 authToken: "bootstrap-token",
168168 autoBootstrapProjectFromCwd: false,
169169 logWebSocketEvents: true,
170 otlpTracesUrl: "http://localhost:4318/v1/traces",
171 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
170172 });
171173 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
172174
......@@ -202,6 +204,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
202204 expect(resolved).toEqual({
203205 logLevel: "Info",
204206 ...defaultObservabilityConfig,
207 otlpTracesUrl: "http://localhost:4318/v1/traces",
208 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
205209 mode: "desktop",
206210 port: 4888,
207211 cwd: process.cwd(),
......@@ -332,4 +336,67 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
332336 });
333337 }),
334338 );
339
340 it.effect("falls back to persisted observability settings when env vars are absent", () =>
341 Effect.gen(function* () {
342 const fs = yield* FileSystem.FileSystem;
343 const path = yield* Path.Path;
344 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" });
345 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
346 yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true });
347 yield* fs.writeFileString(
348 derivedPaths.settingsPath,
349 `${JSON.stringify({
350 observability: {
351 otlpTracesUrl: "http://localhost:4318/v1/traces",
352 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
353 },
354 })}\n`,
355 );
356
357 const resolved = yield* resolveServerConfig(
358 {
359 mode: Option.some("desktop"),
360 port: Option.some(4888),
361 host: Option.none(),
362 baseDir: Option.some(baseDir),
363 devUrl: Option.none(),
364 noBrowser: Option.none(),
365 authToken: Option.none(),
366 bootstrapFd: Option.none(),
367 autoBootstrapProjectFromCwd: Option.none(),
368 logWebSocketEvents: Option.none(),
369 },
370 Option.none(),
371 ).pipe(
372 Effect.provide(
373 Layer.mergeAll(
374 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
375 NetService.layer,
376 ),
377 ),
378 );
379
380 expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
381 expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics");
382 expect(resolved).toEqual({
383 logLevel: "Info",
384 ...defaultObservabilityConfig,
385 otlpTracesUrl: "http://localhost:4318/v1/traces",
386 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
387 mode: "desktop",
388 port: 4888,
389 cwd: process.cwd(),
390 baseDir,
391 ...derivedPaths,
392 host: "127.0.0.1",
393 staticDir: resolved.staticDir,
394 devUrl: undefined,
395 noBrowser: true,
396 authToken: undefined,
397 autoBootstrapProjectFromCwd: false,
398 logWebSocketEvents: false,
399 });
400 }),
401 );
335402});
apps/server/src/cli.ts+33-2
......@@ -1,4 +1,5 @@
11import { NetService } from "@t3tools/shared/Net";
2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
23import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect";
34import { Command, Flag, GlobalFlag } from "effect/unstable/cli";
45
......@@ -27,6 +28,8 @@ const BootstrapEnvelopeSchema = Schema.Struct({
2728 authToken: Schema.optional(Schema.String),
2829 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
2930 logWebSocketEvents: Schema.optional(Schema.Boolean),
31 otlpTracesUrl: Schema.optional(Schema.String),
32 otlpMetricsUrl: Schema.optional(Schema.String),
3033});
3134
3235const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
......@@ -152,6 +155,17 @@ const resolveOptionPrecedence = <Value>(
152155 ...values: ReadonlyArray<Option.Option<Value>>
153156): Option.Option<Value> => Option.firstSomeOf(values);
154157
158const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) {
159 const fs = yield* FileSystem.FileSystem;
160 const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false));
161 if (!exists) {
162 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
163 }
164
165 const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => ""));
166 return parsePersistedServerObservabilitySettings(raw);
167});
168
155169export const resolveServerConfig = (
156170 flags: CliServerFlags,
157171 cliLogLevel: Option.Option<LogLevel.LogLevel>,
......@@ -213,6 +227,9 @@ export const resolveServerConfig = (
213227 );
214228 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
215229 yield* ensureServerDirectories(derivedPaths);
230 const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings(
231 derivedPaths.settingsPath,
232 );
216233 const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;
217234 yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });
218235 const noBrowser = resolveBooleanFlag(
......@@ -278,8 +295,22 @@ export const resolveServerConfig = (
278295 traceBatchWindowMs: env.traceBatchWindowMs,
279296 traceMaxBytes: env.traceMaxBytes,
280297 traceMaxFiles: env.traceMaxFiles,
281 otlpTracesUrl: env.otlpTracesUrl,
282 otlpMetricsUrl: env.otlpMetricsUrl,
298 otlpTracesUrl:
299 env.otlpTracesUrl ??
300 Option.getOrUndefined(
301 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
302 Option.fromUndefinedOr(bootstrap.otlpTracesUrl),
303 ),
304 ) ??
305 persistedObservabilitySettings.otlpTracesUrl,
306 otlpMetricsUrl:
307 env.otlpMetricsUrl ??
308 Option.getOrUndefined(
309 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
310 Option.fromUndefinedOr(bootstrap.otlpMetricsUrl),
311 ),
312 ) ??
313 persistedObservabilitySettings.otlpMetricsUrl,
283314 otlpExportIntervalMs: env.otlpExportIntervalMs,
284315 otlpServiceName: env.otlpServiceName,
285316 mode,
apps/server/src/serverSettings.test.ts+26
......@@ -171,6 +171,24 @@ it.layer(NodeServices.layer)("server settings", (it) => {
171171 }).pipe(Effect.provide(makeServerSettingsLayer())),
172172 );
173173
174 it.effect("trims observability settings when updates are applied", () =>
175 Effect.gen(function* () {
176 const serverSettings = yield* ServerSettingsService;
177
178 const next = yield* serverSettings.updateSettings({
179 observability: {
180 otlpTracesUrl: " http://localhost:4318/v1/traces ",
181 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
182 },
183 });
184
185 assert.deepEqual(next.observability, {
186 otlpTracesUrl: "http://localhost:4318/v1/traces",
187 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
188 });
189 }).pipe(Effect.provide(makeServerSettingsLayer())),
190 );
191
174192 it.effect("defaults blank binary paths to provider executables", () =>
175193 Effect.gen(function* () {
176194 const serverSettings = yield* ServerSettingsService;
......@@ -197,6 +215,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {
197215 const serverConfig = yield* ServerConfig;
198216 const fileSystem = yield* FileSystem.FileSystem;
199217 const next = yield* serverSettings.updateSettings({
218 observability: {
219 otlpTracesUrl: "http://localhost:4318/v1/traces",
220 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
221 },
200222 providers: {
201223 codex: {
202224 binaryPath: "/opt/homebrew/bin/codex",
......@@ -208,6 +230,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {
208230
209231 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
210232 assert.deepEqual(JSON.parse(raw), {
233 observability: {
234 otlpTracesUrl: "http://localhost:4318/v1/traces",
235 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
236 },
211237 providers: {
212238 codex: {
213239 binaryPath: "/opt/homebrew/bin/codex",
apps/web/src/components/KeybindingsToast.browser.tsx+2
......@@ -1,6 +1,7 @@
11import "../index.css";
22
33import {
4 DEFAULT_SERVER_SETTINGS,
45 ORCHESTRATION_WS_METHODS,
56 type MessageId,
67 type OrchestrationReadModel,
......@@ -63,6 +64,7 @@ function createBaseServerConfig(): ServerConfig {
6364 otlpMetricsEnabled: false,
6465 },
6566 settings: {
67 ...DEFAULT_SERVER_SETTINGS,
6668 enableAssistantStreaming: false,
6769 defaultThreadEnvMode: "local" as const,
6870 textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" },
packages/contracts/src/settings.ts+13
......@@ -71,6 +71,12 @@ export const ClaudeSettings = Schema.Struct({
7171});
7272export type ClaudeSettings = typeof ClaudeSettings.Type;
7373
74export const ObservabilitySettings = Schema.Struct({
75 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")),
76 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")),
77});
78export type ObservabilitySettings = typeof ObservabilitySettings.Type;
79
7480export const ServerSettings = Schema.Struct({
7581 enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),
7682 defaultThreadEnvMode: ThreadEnvMode.pipe(
......@@ -88,6 +94,7 @@ export const ServerSettings = Schema.Struct({
8894 codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))),
8995 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))),
9096 }).pipe(Schema.withDecodingDefault(() => ({}))),
97 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(() => ({}))),
9198});
9299export type ServerSettings = typeof ServerSettings.Type;
93100
......@@ -158,6 +165,12 @@ export const ServerSettingsPatch = Schema.Struct({
158165 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
159166 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
160167 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
168 observability: Schema.optionalKey(
169 Schema.Struct({
170 otlpTracesUrl: Schema.optionalKey(Schema.String),
171 otlpMetricsUrl: Schema.optionalKey(Schema.String),
172 }),
173 ),
161174 providers: Schema.optionalKey(
162175 Schema.Struct({
163176 codex: Schema.optionalKey(CodexSettingsPatch),
packages/shared/package.json+4
......@@ -40,6 +40,10 @@
4040 "types": "./src/Struct.ts",
4141 "import": "./src/Struct.ts"
4242 },
43 "./serverSettings": {
44 "types": "./src/serverSettings.ts",
45 "import": "./src/serverSettings.ts"
46 },
4347 "./String": {
4448 "types": "./src/String.ts",
4549 "import": "./src/String.ts"
packages/shared/src/serverSettings.test.ts created+53
......@@ -0,0 +1,53 @@
1import { describe, expect, it } from "vitest";
2import {
3 extractPersistedServerObservabilitySettings,
4 normalizePersistedServerSettingString,
5 parsePersistedServerObservabilitySettings,
6} from "./serverSettings";
7
8describe("serverSettings helpers", () => {
9 it("normalizes optional persisted strings", () => {
10 expect(normalizePersistedServerSettingString(undefined)).toBeUndefined();
11 expect(normalizePersistedServerSettingString(" ")).toBeUndefined();
12 expect(normalizePersistedServerSettingString(" http://localhost:4318/v1/traces ")).toBe(
13 "http://localhost:4318/v1/traces",
14 );
15 });
16
17 it("extracts persisted observability settings", () => {
18 expect(
19 extractPersistedServerObservabilitySettings({
20 observability: {
21 otlpTracesUrl: " http://localhost:4318/v1/traces ",
22 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
23 },
24 }),
25 ).toEqual({
26 otlpTracesUrl: "http://localhost:4318/v1/traces",
27 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
28 });
29 });
30
31 it("parses lenient persisted settings JSON", () => {
32 expect(
33 parsePersistedServerObservabilitySettings(
34 JSON.stringify({
35 observability: {
36 otlpTracesUrl: "http://localhost:4318/v1/traces",
37 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
38 },
39 }),
40 ),
41 ).toEqual({
42 otlpTracesUrl: "http://localhost:4318/v1/traces",
43 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
44 });
45 });
46
47 it("falls back cleanly when persisted settings are invalid", () => {
48 expect(parsePersistedServerObservabilitySettings("{")).toEqual({
49 otlpTracesUrl: undefined,
50 otlpMetricsUrl: undefined,
51 });
52 });
53});
packages/shared/src/serverSettings.ts created+40
......@@ -0,0 +1,40 @@
1import { ServerSettings } from "@t3tools/contracts";
2import { Schema } from "effect";
3import { fromLenientJson } from "./schemaJson";
4
5const ServerSettingsJson = fromLenientJson(ServerSettings);
6
7export interface PersistedServerObservabilitySettings {
8 readonly otlpTracesUrl: string | undefined;
9 readonly otlpMetricsUrl: string | undefined;
10}
11
12export function normalizePersistedServerSettingString(
13 value: string | null | undefined,
14): string | undefined {
15 const trimmed = value?.trim();
16 return trimmed && trimmed.length > 0 ? trimmed : undefined;
17}
18
19export function extractPersistedServerObservabilitySettings(input: {
20 readonly observability?: {
21 readonly otlpTracesUrl?: string;
22 readonly otlpMetricsUrl?: string;
23 };
24}): PersistedServerObservabilitySettings {
25 return {
26 otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl),
27 otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl),
28 };
29}
30
31export function parsePersistedServerObservabilitySettings(
32 raw: string,
33): PersistedServerObservabilitySettings {
34 try {
35 const decoded = Schema.decodeUnknownSync(ServerSettingsJson)(raw);
36 return extractPersistedServerObservabilitySettings(decoded);
37 } catch {
38 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
39 }
40}