From 3bb06ad910389fcfc886bd5156f41dc73f307f80 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 18 Sep 2026 18:55:15 -0400 Subject: [PATCH 01/25] feat(server): export log records over OTLP (#12493) Signed-off-by: Yordis Prieto --- apps/desktop/src/app/DesktopConfig.ts | 2 + .../src/app/DesktopEnvironment.test.ts | 4 + apps/desktop/src/app/DesktopEnvironment.ts | 4 + .../DesktopBackendConfiguration.test.ts | 103 +++++++++ .../backend/DesktopBackendConfiguration.ts | 25 ++- apps/server/src/bin.test.ts | 1 + apps/server/src/cli/config.test.ts | 44 ++++ apps/server/src/cli/config.ts | 8 +- apps/server/src/cli/pair.ts | 1 + apps/server/src/config.ts | 14 ++ .../src/environment/ServerEnvironment.test.ts | 1 + .../src/observability/Layers/Observability.ts | 17 +- apps/server/src/server.test.ts | 4 + apps/server/src/serverLogger.test.ts | 197 ++++++++++++++++++ apps/server/src/serverLogger.ts | 42 +++- apps/server/src/serverSettings.test.ts | 2 + apps/server/src/ws.ts | 2 + docs/operations/observability.md | 19 +- .../client-runtime/src/rpc/session.test.ts | 1 + packages/contracts/src/desktopBootstrap.ts | 1 + packages/contracts/src/server.test.ts | 17 ++ packages/contracts/src/server.ts | 4 + packages/contracts/src/settings.ts | 2 + packages/shared/src/serverSettings.test.ts | 9 +- packages/shared/src/serverSettings.ts | 5 +- 25 files changed, 503 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/serverLogger.test.ts diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index d03dec70ce82..773ea24509cf 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -46,6 +46,8 @@ export const DesktopConfig = Config.all({ desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"), desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"), otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"), + otlpMetricsUrl: trimmedString("T3CODE_OTLP_METRICS_URL"), + otlpLogsUrl: trimmedString("T3CODE_OTLP_LOGS_URL"), otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 0e5fbecd0224..b5aabf253d0f 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -51,6 +51,8 @@ describe("DesktopEnvironment", () => { VITE_DEV_SERVER_URL: "http://localhost:5173", T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", + T3CODE_OTLP_METRICS_URL: " http://127.0.0.1:4318/v1/metrics ", + T3CODE_OTLP_LOGS_URL: " http://127.0.0.1:4318/v1/logs ", T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", T3CODE_OTLP_PROTOCOL: "http/protobuf", @@ -86,6 +88,8 @@ describe("DesktopEnvironment", () => { assert.deepEqual(environment.configuredBackendPort, Option.some(4949)); assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); + assert.deepEqual(environment.otlpMetricsUrl, Option.some("http://127.0.0.1:4318/v1/metrics")); + assert.deepEqual(environment.otlpLogsUrl, Option.some("http://127.0.0.1:4318/v1/logs")); assert.equal(environment.otlpExportIntervalMs, 2500); assert.deepEqual( environment.otlpHeaders, diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index e7a489d5e89d..d6924b08acec 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -72,6 +72,8 @@ export class DesktopEnvironment extends Context.Service< readonly configuredBackendPort: Option.Option; readonly commitHashOverride: Option.Option; readonly otlpTracesUrl: Option.Option; + readonly otlpMetricsUrl: Option.Option; + readonly otlpLogsUrl: Option.Option; readonly otlpExportIntervalMs: number; readonly otlpHeaders: Option.Option>; readonly otlpProtocol: OtlpProtocol; @@ -227,6 +229,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( configuredBackendPort: config.configuredBackendPort, commitHashOverride: config.commitHashOverride, otlpTracesUrl: config.otlpTracesUrl, + otlpMetricsUrl: config.otlpMetricsUrl, + otlpLogsUrl: config.otlpLogsUrl, otlpExportIntervalMs: config.otlpExportIntervalMs, otlpHeaders: config.otlpHeaders, otlpProtocol: config.otlpProtocol, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 595582a16280..41eeedc668b6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -23,6 +23,7 @@ const PersistedServerObservabilitySettingsDocument = Schema.Struct({ observability: Schema.Struct({ otlpTracesUrl: Schema.String, otlpMetricsUrl: Schema.String, + otlpLogsUrl: Schema.String, }), }); @@ -60,6 +61,9 @@ function makeEnvironmentLayer( readonly resourcesPath?: string; readonly appVersion?: string; readonly processArch?: NodeJS.Architecture; + readonly otlpTracesUrl?: string; + readonly otlpMetricsUrl?: string; + readonly otlpLogsUrl?: string; }, ) { return DesktopEnvironment.layer({ @@ -82,6 +86,9 @@ function makeEnvironmentLayer( T3CODE_MODE: "desktop", T3CODE_DESKTOP_LAN_HOST: "192.168.1.50", VITE_DEV_SERVER_URL: options?.devServerUrl, + T3CODE_OTLP_TRACES_URL: options?.otlpTracesUrl, + T3CODE_OTLP_METRICS_URL: options?.otlpMetricsUrl, + T3CODE_OTLP_LOGS_URL: options?.otlpLogsUrl, }), ), ), @@ -730,6 +737,7 @@ describe("DesktopBackendConfiguration", () => { observability: { otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", + otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ", }, }), ); @@ -737,6 +745,7 @@ describe("DesktopBackendConfiguration", () => { const config = yield* configuration.resolvePrimary; assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); + assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); }), ), ); @@ -749,10 +758,103 @@ describe("DesktopBackendConfiguration", () => { assert.isUndefined(config.bootstrap.otlpTracesUrl); assert.isUndefined(config.bootstrap.otlpMetricsUrl); + assert.isUndefined(config.bootstrap.otlpLogsUrl); }), ), ); + it.effect("resolveWsl carries environment-configured observability endpoints", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + // No settings.json exists here: the endpoints come from the desktop + // process's env, which a WSL child cannot inherit, so the bootstrap + // has to carry them or log export stays off inside the distro. + assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); + assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); + assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); + assert.notInclude(config.env.WSLENV ?? "", "T3CODE_OTLP_LOGS_URL"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + platform: "win32", + otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", + otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", + otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ", + }), + ), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("environment observability endpoints win over the persisted settings file", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { + recursive: true, + }); + yield* fileSystem.writeFileString( + environment.serverSettingsPath, + yield* encodePersistedServerObservabilitySettingsDocument({ + observability: { + otlpTracesUrl: "http://persisted:4318/v1/traces", + otlpMetricsUrl: "http://persisted:4318/v1/metrics", + otlpLogsUrl: "http://persisted:4318/v1/logs", + }, + }), + ); + + const config = yield* configuration.resolvePrimary; + assert.equal(config.bootstrap.otlpLogsUrl, "http://env:4318/v1/logs"); + // Only the logs endpoint is set in env, so the other two still come + // from the settings file rather than being dropped together. + assert.equal(config.bootstrap.otlpTracesUrl, "http://persisted:4318/v1/traces"); + assert.equal(config.bootstrap.otlpMetricsUrl, "http://persisted:4318/v1/metrics"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { otlpLogsUrl: "http://env:4318/v1/logs" }), + ), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("logs structured context when persisted observability settings cannot be read", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -799,6 +901,7 @@ describe("DesktopBackendConfiguration", () => { assert.isUndefined(config.bootstrap.otlpTracesUrl); assert.isUndefined(config.bootstrap.otlpMetricsUrl); + assert.isUndefined(config.bootstrap.otlpLogsUrl); const error = messages .flatMap((message) => (Array.isArray(message) ? message : [message])) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index a36902304809..9e809667ac42 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -67,11 +67,13 @@ export class DesktopBackendConfiguration extends Context.Service< interface BackendObservabilitySettings { readonly otlpTracesUrl: Option.Option; readonly otlpMetricsUrl: Option.Option; + readonly otlpLogsUrl: Option.Option; } const emptyBackendObservabilitySettings: BackendObservabilitySettings = { otlpTracesUrl: Option.none(), otlpMetricsUrl: Option.none(), + otlpLogsUrl: Option.none(), }; const DESKTOP_BACKEND_ENV_NAMES = [ @@ -212,9 +214,26 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { return { otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl), otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl), + otlpLogsUrl: Option.fromNullishOr(parsed.otlpLogsUrl), }; }); +// The bootstrap is the only channel that carries an OTLP endpoint to every +// backend. A Windows-native child inherits the desktop process's env, but a +// WSL child gets nothing across wsl.exe that WSLENV does not declare, and +// WSLENV translation of URL-shaped values is unreliable, so the endpoints are +// deliberately not forwarded that way. Env beats the persisted settings file, +// matching the precedence resolveServerConfig and DesktopObservability apply. +const readBackendObservabilitySettings = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const persisted = yield* readPersistedBackendObservabilitySettings; + return { + otlpTracesUrl: Option.orElse(environment.otlpTracesUrl, () => persisted.otlpTracesUrl), + otlpMetricsUrl: Option.orElse(environment.otlpMetricsUrl, () => persisted.otlpMetricsUrl), + otlpLogsUrl: Option.orElse(environment.otlpLogsUrl, () => persisted.otlpLogsUrl), + } satisfies BackendObservabilitySettings; +}); + interface SharedBootstrapInput { readonly bootstrapToken: string; readonly observabilitySettings: BackendObservabilitySettings; @@ -488,6 +507,10 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS onNone: () => ({}), onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), }), + ...Option.match(observabilitySettings.otlpLogsUrl, { + onNone: () => ({}), + onSome: (otlpLogsUrl) => ({ otlpLogsUrl }), + }), }); const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( @@ -804,7 +827,7 @@ export const make = Effect.gen(function* () { // restart cycle without having to bounce the desktop process. const sharedInputs = Effect.gen(function* () { const bootstrapToken = yield* getOrCreateBootstrapToken; - const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( + const observabilitySettings = yield* readBackendObservabilitySettings.pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index c1bea133f718..795bb05db148 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -100,6 +100,7 @@ const makeCliTestServerConfig = (baseDir: string) => traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 8c4dbbcdbbf2..aea753c3398f 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -50,6 +50,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, @@ -398,6 +399,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServePort: 443, otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", }), ); const derivedPaths = yield* deriveServerPaths(baseDir, undefined); @@ -438,6 +440,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ...defaultObservabilityConfig, otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", mode: "desktop", port: 4888, cwd: process.cwd(), @@ -604,6 +607,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", }, })}\n`, ); @@ -635,11 +639,13 @@ 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.otlpLogsUrl).toBe("http://localhost:4318/v1/logs"); expect(resolved).toEqual({ logLevel: "Info", ...defaultObservabilityConfig, otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", mode: "desktop", port: 4888, cwd: process.cwd(), @@ -845,4 +851,42 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpProtocol).toBe("http/protobuf"); }), ); + + it.effect("reads the OTLP logs URL from env", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-logs-url-base"); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { T3CODE_OTLP_LOGS_URL: "http://collector.internal:4318/v1/logs" }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved.otlpLogsUrl).toBe("http://collector.internal:4318/v1/logs"); + }), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 6db6abbcc8a1..32f465b6fb0a 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -96,6 +96,10 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + otlpLogsUrl: Config.String("T3CODE_OTLP_LOGS_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), @@ -227,7 +231,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, otlpMetricsUrl: undefined }; + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined }; } const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); @@ -393,6 +397,8 @@ export const resolveServerConfig = ( env.otlpMetricsUrl ?? bootstrap?.otlpMetricsUrl ?? persistedObservabilitySettings.otlpMetricsUrl, + otlpLogsUrl: + env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl, otlpExportIntervalMs: env.otlpExportIntervalMs, otlpServiceName: env.otlpServiceName, otlpHeaders: env.otlpHeaders, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 94c88b294770..9ff9a8e13b39 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -320,6 +320,7 @@ const makePairServerConfig = Effect.fn(function* (input: { traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 919628c8d1ed..4835ebb40e44 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service< readonly traceMaxFiles: number; readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; + readonly otlpLogsUrl: string | undefined; readonly otlpExportIntervalMs: number; readonly otlpServiceName: string; readonly otlpHeaders: Readonly> | undefined; @@ -106,6 +107,18 @@ export class ServerConfig extends Context.Service< export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config); +/** + * Resource attributes shared by every OTLP exporter, so traces, metrics, and + * logs report the same service identity to the collector. + */ +export const otlpResource = (config: ServerConfig["Service"]) => ({ + serviceName: config.otlpServiceName, + attributes: { + "service.runtime": "t3-server", + "service.mode": config.mode, + }, +}); + export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerConfig, make(config)); export const deriveServerPaths = Effect.fn(function* ( @@ -198,6 +211,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index b8cb84349472..0ccf8de691eb 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -53,6 +53,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 7a7aaf910a9b..0c9acdfb1ee3 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -21,6 +21,7 @@ export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const serializationLayer = otlpSerializationLayer(config.otlpProtocol); + const resource = ServerConfig.otlpResource(config); const attribution = yield* ResourceAttribution.ResourceAttribution; const traceReferencesLayer = Layer.mergeAll( @@ -52,13 +53,7 @@ export const ObservabilityLive = Layer.unwrap( url: config.otlpTracesUrl, exportInterval: `${config.otlpExportIntervalMs} millis`, headers: config.otlpHeaders, - resource: { - serviceName: config.otlpServiceName, - attributes: { - "service.runtime": "t3-server", - "service.mode": config.mode, - }, - }, + resource, }); const tracer = yield* makeLocalFileTracer({ @@ -84,13 +79,7 @@ export const ObservabilityLive = Layer.unwrap( url: config.otlpMetricsUrl, exportInterval: `${config.otlpExportIntervalMs} millis`, headers: config.otlpHeaders, - resource: { - serviceName: config.otlpServiceName, - attributes: { - "service.runtime": "t3-server", - "service.mode": config.mode, - }, - }, + resource, }).pipe(Layer.provideMerge(serializationLayer)); return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index cb2cf03659ef..512e55a64cbb 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -580,6 +580,7 @@ const buildAppUnderTest = (options?: { traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, @@ -6291,6 +6292,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { config: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", }, layers: { keybindings: { @@ -6326,6 +6328,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(first.config.observability.otlpTracesEnabled, true); assert.equal(first.config.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics"); assert.equal(first.config.observability.otlpMetricsEnabled, true); + assert.equal(first.config.observability.otlpLogsUrl, "http://localhost:4318/v1/logs"); + assert.equal(first.config.observability.otlpLogsEnabled, true); assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS); } assert.deepEqual(second, { diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts new file mode 100644 index 000000000000..59ca908cb4ad --- /dev/null +++ b/apps/server/src/serverLogger.test.ts @@ -0,0 +1,197 @@ +import * as NodePath from "@effect/platform-node/NodePath"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeOS from "node:os"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Tracer from "effect/Tracer"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as ServerConfig from "./config.ts"; +import { ServerLoggerLive } from "./serverLogger.ts"; + +interface ExportedRequest { + readonly url: string; + readonly headers: Readonly>; + readonly body: string; +} + +/** Answers every export with a 200 and keeps what was posted for assertions. */ +const collectorLayer = (requests: Array) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push({ + url: request.url, + headers: request.headers, + body: + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "", + }); + return HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })); + }), + ), + ); + +const configLayer = (overrides: Partial) => + Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const path = yield* Path.Path; + const baseDir = path.join(NodeOS.tmpdir(), "t3-server-logger-test"); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + return ServerConfig.make({ + logLevel: "Info", + traceMinLevel: "Info", + traceTimingEnabled: false, + traceBatchWindowMs: 200, + traceMaxBytes: 1024, + traceMaxFiles: 1, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + otlpHeaders: undefined, + otlpProtocol: "http/json", + cwd: baseDir, + baseDir, + ...derivedPaths, + mode: "web", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + port: 0, + host: undefined, + desktopBootstrapToken: undefined, + desktopTelemetryFd: undefined, + desktopTelemetryControlFd: undefined, + resourceMonitorPath: undefined, + staticDir: undefined, + devUrl: undefined, + devAllowedOrigins: [], + noBrowser: false, + startupPresentation: "browser", + ...overrides, + }); + }), + ).pipe(Layer.provide(NodePath.layer)); + +/** + * Logs once with the server's own logger set installed, then reports what the + * collector received. The export is asserted after the layer's scope closes, + * which is where the exporter flushes whatever the interval did not. + */ +const logThrough = (overrides: Partial) => + Effect.gen(function* () { + const requests: Array = []; + yield* Effect.log("server logger under test").pipe( + Effect.provide( + ServerLoggerLive.pipe( + Layer.provide(configLayer(overrides)), + Layer.provide(collectorLayer(requests)), + ), + ), + ); + return requests; + }); + +/** + * Logs inside a span so the tracer logger has somewhere to attach an event, + * then reports both what the collector received and what landed on the span. + */ +const logInSpanThrough = (overrides: Partial) => + Effect.gen(function* () { + const requests: Array = []; + const spans: Array = []; + const tracerLayer = Layer.succeed( + Tracer.Tracer, + Tracer.make({ + span: (spanOptions) => { + const span = new Tracer.NativeSpan(spanOptions); + spans.push(span); + return span; + }, + }), + ); + yield* Effect.log("server logger under test").pipe( + Effect.withSpan("server-logger-test"), + Effect.provide( + Layer.mergeAll( + ServerLoggerLive.pipe( + Layer.provide(configLayer(overrides)), + Layer.provide(collectorLayer(requests)), + ), + tracerLayer, + ), + ), + ); + return { requests, spans }; + }); + +describe("ServerLoggerLive", () => { + it.effect("exports log records to the configured logs endpoint", () => + Effect.gen(function* () { + const requests = yield* logThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + }); + + assert.lengthOf(requests, 1); + const [request] = requests; + assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); + assert.include(request?.body ?? "", "server logger under test"); + assert.include(request?.body ?? "", "t3-server"); + assert.include(request?.body ?? "", "service.runtime"); + }), + ); + + it.effect("stays off the network when no logs endpoint is configured", () => + Effect.gen(function* () { + const requests = yield* logThrough({}); + + assert.lengthOf(requests, 0); + }), + ); + + it.effect("sends the headers and wire format the rest of OTLP export already uses", () => + Effect.gen(function* () { + const requests = yield* logThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + otlpProtocol: "http/protobuf", + otlpHeaders: { "x-scope": "logs" }, + }); + + assert.lengthOf(requests, 1); + assert.strictEqual(requests[0]?.headers["x-scope"], "logs"); + assert.strictEqual(requests[0]?.headers["content-type"], "application/x-protobuf"); + }), + ); + + it.effect("attaches log messages to the active span when no logs endpoint is configured", () => + Effect.gen(function* () { + const { requests, spans } = yield* logInSpanThrough({}); + + assert.lengthOf(requests, 0); + assert.lengthOf(spans, 1); + assert.deepEqual( + spans[0]?.events.map(([name]) => name), + ["server logger under test"], + ); + }), + ); + + it.effect("stops duplicating messages onto the span once log records are exported", () => + Effect.gen(function* () { + const { requests, spans } = yield* logInSpanThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + }); + + assert.lengthOf(requests, 1); + assert.include(requests[0]?.body ?? "", "server logger under test"); + assert.lengthOf(spans, 1); + assert.lengthOf(spans[0]?.events ?? [], 0); + }), + ); +}); diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index a7cb1d6a26ee..389a9535efab 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,16 +1,48 @@ +import { otlpSerializationLayer } from "@t3tools/shared/observability"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as References from "effect/References"; -import * as Layer from "effect/Layer"; +import * as OtlpExporter from "effect/unstable/observability/OtlpExporter"; +import * as OtlpLogger from "effect/unstable/observability/OtlpLogger"; -import { ServerConfig } from "./config.ts"; +import { otlpResource, ServerConfig } from "./config.ts"; export const ServerLoggerLive = Effect.gen(function* () { const config = yield* ServerConfig; const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel); - const loggerLayer = Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { - mergeWithExisting: false, - }); + + const otlpLogger = + config.otlpLogsUrl === undefined + ? undefined + : OtlpLogger.make({ + url: config.otlpLogsUrl, + exportInterval: `${config.otlpExportIntervalMs} millis`, + headers: config.otlpHeaders, + resource: otlpResource(config), + }); + + // `Logger.layer` writes the whole logger set rather than adding to it, so + // every logger the server wants has to be named in this one call. + // + // `Logger.tracerLogger` reaches a collector by attaching each message to the + // active span as a span event, which covers only messages logged inside a + // recorded span and files them under traces. The OTLP logger carries the same + // messages as log records stamped with their trace and span ids, so it is a + // superset: keeping both would export every in-span message twice. + // + // Recording events on spans is also the shape OpenTelemetry is deprecating, + // in favor of the log-based events this logger emits: + // https://opentelemetry.io/blog/2026/deprecating-span-events/ + const loggerLayer = Logger.layer( + otlpLogger === undefined + ? [Logger.consolePretty(), Logger.tracerLogger] + : [Logger.consolePretty(), otlpLogger], + { mergeWithExisting: false }, + ).pipe( + Layer.provide(OtlpExporter.layerFlusher), + Layer.provide(otlpSerializationLayer(config.otlpProtocol)), + ); return Layer.mergeAll(loggerLayer, minimumLogLevelLayer); }).pipe(Layer.unwrap); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index bd216a0e88a1..b2caba4a0347 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -969,6 +969,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", otlpMetricsUrl: " http://localhost:4318/v1/metrics ", + otlpLogsUrl: " http://localhost:4318/v1/logs ", }, }); @@ -976,6 +977,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual(next.observability, { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0b9bd1136bf3..d118bd3d012c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1808,6 +1808,8 @@ const makeWsRpcLayer = ( ? { otlpMetricsUrl: config.otlpMetricsUrl } : {}), otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + ...(config.otlpLogsUrl !== undefined ? { otlpLogsUrl: config.otlpLogsUrl } : {}), + otlpLogsEnabled: config.otlpLogsUrl !== undefined, }, settings, shellResumeCompletionMarker: true, diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 5668ca9d6fbb..215b1706aeef 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -6,7 +6,7 @@ T3 Code has one server-side observability model: - pretty logs go to stdout for humans - completed spans go to a local NDJSON trace file -- traces and metrics can also be exported over OTLP to a real backend like Grafana LGTM +- traces, metrics, and logs can also be exported over OTLP to a real backend like Grafana LGTM The local trace file is the persisted source of truth for normal local launches. Those launches do not write a separate server log file, but SSH-managed launches also persist the remote process's @@ -22,9 +22,16 @@ Logs are human-facing: - format: `Logger.consolePretty()` - normal local persistence: none - SSH-managed launch persistence: `~/.t3/ssh-launch//server.log` +- remote export: OTLP only, when configured If you want a log message to show up in the trace file, emit it inside an active span with `Effect.log...`. `Logger.tracerLogger` will attach it as a span event. +Configuring a logs endpoint takes over that job. The server then exports log records, which cover +every message instead of only the ones inside an active span and carry the trace and span ids so +they still line up with the trace. `Logger.tracerLogger` is dropped in that mode, so the same +message is not exported twice and the trace file stops carrying log messages. stdout output and +SSH-managed launch persistence stay unchanged either way. + ### Traces Completed spans are written as NDJSON records to `serverTracePath`. The default depends on how the @@ -121,6 +128,7 @@ Default Grafana login: ```bash export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics +export T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs export T3CODE_OTLP_SERVICE_NAME=t3-local ``` @@ -160,6 +168,7 @@ macOS app bundle example: ```bash T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \ T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \ +T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs \ T3CODE_OTLP_SERVICE_NAME=t3-desktop \ "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/Applications/T3 Code.app/Contents/MacOS/T3 Code" ``` @@ -169,6 +178,7 @@ Direct binary example: ```bash T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \ T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \ +T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs \ T3CODE_OTLP_SERVICE_NAME=t3-desktop \ ./path/to/your/desktop-app-binary ``` @@ -509,6 +519,7 @@ It provides: - local NDJSON tracer - optional OTLP trace exporter - optional OTLP metrics exporter +- optional OTLP log exporter - Effect trace-level and timing refs ### Env Vars @@ -526,13 +537,15 @@ OTLP export: - `T3CODE_OTLP_TRACES_URL`: OTLP trace endpoint - `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint +- `T3CODE_OTLP_LOGS_URL`: OTLP log endpoint - `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000` - `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server` -- `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as +- `T3CODE_OTLP_HEADERS`: extra headers for all three exporters, same format as `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values. - `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf` -If the OTLP URLs are unset, local tracing still works and metrics stay in-process only. +If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay +on stdout only. ### What Is Instrumented Today diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 4687b84c359f..65b537921f09 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -149,6 +149,7 @@ const SERVER_CONFIG: ServerConfigType = { localTracingEnabled: false, otlpTracesEnabled: false, otlpMetricsEnabled: false, + otlpLogsEnabled: false, }, settings: DEFAULT_SERVER_SETTINGS, }; diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index f4d1a0927861..bc3558aa2962 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -16,6 +16,7 @@ export const DesktopBackendBootstrap = Schema.Struct({ tailscaleServePort: PortSchema, otlpTracesUrl: Schema.optional(Schema.String), otlpMetricsUrl: Schema.optional(Schema.String), + otlpLogsUrl: Schema.optional(Schema.String), desktopTelemetryFd: Schema.optionalKey(PositiveInt), desktopTelemetryControlFd: Schema.optionalKey(PositiveInt), resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 0a7e342ab752..9112896eb1c9 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -5,6 +5,7 @@ import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { resolveEnvironmentMachineKind, ServerConfig, + ServerObservability, ServerProvider, ServerProviders, ServerUpsertKeybindingResult, @@ -13,6 +14,7 @@ import { ServerSettings } from "./settings.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders); +const decodeServerObservability = Schema.decodeUnknownSync(ServerObservability); const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors); @@ -177,6 +179,21 @@ describe("server config forward compatibility", () => { }); }); +describe("ServerObservability", () => { + it("reads a server from before the log signal as exporting no logs", () => { + const parsed = decodeServerObservability({ + logsDirectoryPath: "/tmp/t3/logs", + localTracingEnabled: true, + otlpTracesUrl: "https://collector.example.com/v1/traces", + otlpTracesEnabled: true, + otlpMetricsEnabled: false, + }); + + expect(parsed.otlpLogsEnabled).toBe(false); + expect(parsed.otlpLogsUrl).toBeUndefined(); + }); +}); + describe("resolveEnvironmentMachineKind", () => { const decodeDescriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor); const decodeSettings = Schema.decodeUnknownSync(ServerSettings); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 8073b3f4ed0a..49a377208799 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -261,6 +261,10 @@ export const ServerObservability = Schema.Struct({ otlpTracesEnabled: Schema.Boolean, otlpMetricsUrl: Schema.optional(TrimmedNonEmptyString), otlpMetricsEnabled: Schema.Boolean, + otlpLogsUrl: Schema.optional(TrimmedNonEmptyString), + // Absent on servers from before the log signal shipped, so a newer client + // reads those as having no log export rather than rejecting the whole config. + otlpLogsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), }); export type ServerObservability = typeof ServerObservability.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9de1f5ebb8dc..42e91a76ecaf 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -889,6 +889,7 @@ export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + otlpLogsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), }); export type ObservabilitySettings = typeof ObservabilitySettings.Type; @@ -1481,6 +1482,7 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Struct({ otlpTracesUrl: Schema.optionalKey(TrimmedString), otlpMetricsUrl: Schema.optionalKey(TrimmedString), + otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), providers: Schema.optionalKey( diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 267e7a7b3442..05f625a1bd0f 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -224,14 +224,18 @@ describe("serverSettings helpers", () => { expect(parsePersistedServerObservabilitySettings("{}")).toEqual({ otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, }); expect( parsePersistedServerObservabilitySettings( - JSON.stringify({ observability: { otlpTracesUrl: " ", otlpMetricsUrl: "" } }), + JSON.stringify({ + observability: { otlpTracesUrl: " ", otlpMetricsUrl: "", otlpLogsUrl: " " }, + }), ), ).toEqual({ otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, }); }); @@ -242,12 +246,14 @@ describe("serverSettings helpers", () => { observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", otlpMetricsUrl: " http://localhost:4318/v1/metrics ", + otlpLogsUrl: " http://localhost:4318/v1/logs ", }, }), ), ).toEqual({ otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", }); }); @@ -255,6 +261,7 @@ describe("serverSettings helpers", () => { expect(parsePersistedServerObservabilitySettings("{")).toEqual({ otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 61103b77babd..d847cb2b0795 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -102,6 +102,7 @@ export function resolveSourceControlWriterModelSelection( export interface PersistedServerObservabilitySettings { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; + readonly otlpLogsUrl: string | undefined; } function normalizePersistedServerSettingString( @@ -115,11 +116,13 @@ function extractPersistedServerObservabilitySettings(input: { readonly observability?: { readonly otlpTracesUrl?: string; readonly otlpMetricsUrl?: string; + readonly otlpLogsUrl?: string; }; }): PersistedServerObservabilitySettings { return { otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl), otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl), + otlpLogsUrl: normalizePersistedServerSettingString(input.observability?.otlpLogsUrl), }; } @@ -130,7 +133,7 @@ export function parsePersistedServerObservabilitySettings( if (Option.isSome(decoded)) { return extractPersistedServerObservabilitySettings(decoded.value); } - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined }; } function shouldReplaceTextGenerationModelSelection( From ea15f17afd94e7ce7133e52f5b5983910662c9ec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 16:09:00 -0700 Subject: [PATCH 02/25] fix(mobile): use native settings and snooze controls (#12512) --- .../sections/CodeAppearanceSection.tsx | 2 +- .../sections/TerminalAppearanceSection.tsx | 2 +- .../sections/TextAppearanceSection.tsx | 2 +- .../settings/components/SettingsSection.tsx | 6 +- .../threads/CustomSnoozeSheet.android.tsx | 12 +- .../threads/CustomSnoozeSheet.ios.tsx | 296 ++++++++++-------- .../threads/CustomSnoozeSheet.shared.tsx | 4 +- .../features/threads/ThreadSettingsSheet.tsx | 196 ++++++------ .../threads/provider-catalog-refresh.ts | 2 +- .../src/features/usage/UsageRouteScreen.tsx | 6 +- 10 files changed, 284 insertions(+), 244 deletions(-) diff --git a/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx index bb4bb4d0a8fe..6760504bfa28 100644 --- a/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx @@ -26,7 +26,7 @@ export function CodeAppearanceSection() { ); return ( - + + + @@ -30,9 +28,7 @@ export function SettingsSection(props: { className={ Platform.OS === "android" ? "overflow-hidden rounded-[28px] bg-card" - : props.card - ? "overflow-hidden rounded-[24px] border-continuous bg-card" - : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent" + : "overflow-hidden rounded-[24px] border-continuous bg-card" } > {props.children} diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx index 85600838881f..e066c01b43cf 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx @@ -1,7 +1,6 @@ import { MaterialSegmentedButtons } from "../../components/MaterialSegmentedButtons.android"; import { BasicAlertDialog, - Button, Column, DateTimePicker, Host, @@ -240,19 +239,16 @@ export function CustomSnoozeSheet(props: Props) { Cancel - + diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx index 20176064be52..c8d9abbc5fa8 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx @@ -1,27 +1,22 @@ import { - Button, DatePicker, Host, HStack, Picker, Popover, + RNHostView, Spacer, Text, VStack, } from "@expo/ui/swift-ui"; import { - accessibilityAddTraits, accessibilityHidden, - buttonBorderShape, - buttonStyle, clipped, - controlSize, font, datePickerStyle, foregroundStyle, frame, labelsHidden, - labelStyle, padding, pickerStyle, tag, @@ -32,12 +27,17 @@ import { resolveCustomSnooze, type CustomSnoozeInput, } from "@t3tools/client-runtime/state/thread-settled"; -import { useState } from "react"; -import { useWindowDimensions } from "react-native"; +import { useState, type ReactNode } from "react"; +import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { ScrollView, useWindowDimensions, View } from "react-native"; +import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; +import { NativeHeaderToolbar } from "../../native/StackHeader"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); +const SnoozeStack = createNativeStackNavigator<{ CustomSnooze: undefined }>(); const modes = [ { value: "date", label: "Date and time" }, { value: "duration", label: "Duration" }, @@ -52,13 +52,15 @@ export function CustomSnoozeSheet(props: { readonly onClose: () => void; readonly onSnooze: (snoozedUntil: string) => void; }) { - const { width } = useWindowDimensions(); + const { width, height } = useWindowDimensions(); const [mode, setMode] = useState("date"); const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); const [amount, setAmount] = useState(2); const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); const [error, setError] = useState(null); const { themeVariables: colors, themeAppearance } = useAppearancePreferences(); + const popoverWidth = Math.min(360, width - 32); + const popoverHeight = Math.min(error ? 364 : 324, height - 96); const updateDate = (value: Date) => { setDate(value); setError(null); @@ -98,133 +100,179 @@ export function CustomSnoozeSheet(props: { /> - - -