diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 2bbde73abaa..62f885a9ad9 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, }), }); @@ -374,6 +375,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 ", }, }), ); @@ -381,6 +383,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"); }), ), ); @@ -393,6 +396,7 @@ describe("DesktopBackendConfiguration", () => { assert.isUndefined(config.bootstrap.otlpTracesUrl); assert.isUndefined(config.bootstrap.otlpMetricsUrl); + assert.isUndefined(config.bootstrap.otlpLogsUrl); }), ), ); @@ -443,6 +447,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 bcce731a595..fcdcfbcc86c 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 = [ @@ -202,6 +204,7 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { return { otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl), otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl), + otlpLogsUrl: Option.fromNullishOr(parsed.otlpLogsUrl), }; }); @@ -361,6 +364,10 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS onNone: () => ({}), onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), }), + ...Option.match(observabilitySettings.otlpLogsUrl, { + onNone: () => ({}), + onSome: (otlpLogsUrl) => ({ otlpLogsUrl }), + }), }); const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 81ce882dc72..df3ef8389bc 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -75,8 +75,10 @@ const makeCliTestServerConfig = (baseDir: string) => traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpMetricsExportIntervalMs: 10_000, + otlpLogsExportIntervalMs: 10_000, otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, mode: "web", diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 9298f98e7fb..3c021840ec2 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -50,8 +50,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpMetricsExportIntervalMs: 10_000, + otlpLogsExportIntervalMs: 10_000, otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, devAllowedOrigins: [], @@ -300,6 +302,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); @@ -340,6 +343,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(), @@ -534,6 +538,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpTracesUrl).toBe("https://collector.example.com/v1/traces"); expect(resolved.otlpMetricsUrl).toBe("https://collector.example.com/v1/metrics"); + expect(resolved.otlpLogsUrl).toBe("https://collector.example.com/v1/logs"); expect(resolved.otlpServiceName).toBe("t3"); }), ); @@ -548,11 +553,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { OTEL_SERVICE_NAME: "t3", T3CODE_OTLP_TRACES_URL: "", T3CODE_OTLP_METRICS_URL: " ", + T3CODE_OTLP_LOGS_URL: "", T3CODE_OTLP_SERVICE_NAME: "", }); expect(resolved.otlpTracesUrl).toBe("https://collector.example.com/v1/traces"); expect(resolved.otlpMetricsUrl).toBe("https://collector.example.com/v1/metrics"); + expect(resolved.otlpLogsUrl).toBe("https://collector.example.com/v1/logs"); expect(resolved.otlpServiceName).toBe("t3"); }), ); @@ -563,11 +570,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", OTEL_SERVICE_NAME: "t3", T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces", + T3CODE_OTLP_LOGS_URL: "http://localhost:4318/v1/logs", T3CODE_OTLP_SERVICE_NAME: "t3-local", }); expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); expect(resolved.otlpMetricsUrl).toBe("https://collector.example.com/v1/metrics"); + expect(resolved.otlpLogsUrl).toBe("http://localhost:4318/v1/logs"); expect(resolved.otlpServiceName).toBe("t3-local"); }), ); @@ -618,6 +627,23 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); + it.effect("keeps a span's schedule off a T3 Code log endpoint", () => + Effect.gen(function* () { + // Log records batch on their own variable with their own default, so a + // log endpoint that came from a T3 Code name keeps T3 Code's interval + // instead of inheriting the span delay standing next to it. + const resolved = yield* resolveWithEnv({ + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_BSP_SCHEDULE_DELAY: "7000", + T3CODE_OTLP_LOGS_URL: "http://localhost:4318/v1/logs", + }); + + expect(resolved.otelEnvironment.logs.settings).toBeUndefined(); + expect(resolved.otlpExportIntervalMs).toBe(7_000); + expect(resolved.otlpLogsExportIntervalMs).toBe(10_000); + }), + ); + it.effect("does not report a signal as declined while it is exporting", () => Effect.gen(function* () { // grpc turns off the export these variables asked for, and says nothing @@ -631,6 +657,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); expect(resolved.otelEnvironment.traces.declined).toBeUndefined(); expect(resolved.otelEnvironment.metrics.declined).toContain("grpc"); + expect(resolved.otelEnvironment.logs.declined).toContain("grpc"); }), ); @@ -644,6 +671,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpTracesUrl).toBeUndefined(); expect(resolved.otlpMetricsUrl).toBeUndefined(); + expect(resolved.otlpLogsUrl).toBeUndefined(); }), ); @@ -661,6 +689,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`, ); @@ -692,11 +721,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(), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 91c7deb54a6..b26aa879b98 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -95,6 +95,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.option, Config.map(Option.getOrUndefined), @@ -216,7 +220,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(() => "")); @@ -380,10 +384,14 @@ export const resolveServerConfig = ( bootstrap?.otlpMetricsUrl ?? persistedObservabilitySettings.otlpMetricsUrl, ); + const namedLogsUrl = named( + env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl, + ); const otelEnvironment = { ...otel, traces: namedTracesUrl === undefined ? otel.traces : OtelEnvironment.noSignal, metrics: namedMetricsUrl === undefined ? otel.metrics : OtelEnvironment.noSignal, + logs: namedLogsUrl === undefined ? otel.logs : OtelEnvironment.noSignal, } satisfies OtelEnvironment.OtelEnvironment; const config: ServerConfig.ServerConfig["Service"] = { @@ -399,14 +407,20 @@ export const resolveServerConfig = ( otlpMetricsUrl: otelEnvironment.disabled ? undefined : (namedMetricsUrl ?? otelEnvironment.metrics.settings?.url), - // T3 Code has one interval variable and it deliberately covers both - // signals. The per-signal part is the fallback under it: the environment - // names a trace delay and a metric interval separately, so a signal that - // took its endpoint elsewhere must not inherit the other one's. + otlpLogsUrl: otelEnvironment.disabled + ? undefined + : (namedLogsUrl ?? otelEnvironment.logs.settings?.url), + // T3 Code has one interval variable and it deliberately covers every + // signal. The per-signal part is the fallback under it: the environment + // names a span delay, a metric interval, and a log record delay + // separately, so a signal that took its endpoint elsewhere must not + // inherit another one's. otlpExportIntervalMs: env.otlpExportIntervalMs ?? otelEnvironment.traces.settings?.exportIntervalMs ?? 10_000, otlpMetricsExportIntervalMs: env.otlpExportIntervalMs ?? otelEnvironment.metrics.settings?.exportIntervalMs ?? 10_000, + otlpLogsExportIntervalMs: + env.otlpExportIntervalMs ?? otelEnvironment.logs.settings?.exportIntervalMs ?? 10_000, otlpServiceName: named(env.otlpServiceName) ?? otelEnvironment.resource.serviceName ?? "t3-server", otelEnvironment, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 16a46d097b8..11a19a9bef6 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -331,8 +331,10 @@ const makePairServerConfig = Effect.fn(function* (input: { traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpMetricsExportIntervalMs: 10_000, + otlpLogsExportIntervalMs: 10_000, otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, mode: "web", diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 07e30942192..2fb76e29529 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -65,8 +65,10 @@ 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 otlpMetricsExportIntervalMs: number; + readonly otlpLogsExportIntervalMs: number; readonly otlpServiceName: string; /** * What the standard `OTEL_*` variables asked for. The endpoints above are @@ -106,6 +108,23 @@ export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config) export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerConfig, make(config)); +/** + * The OTLP resource every exported signal is tagged with. Traces, metrics, and + * logs read it from here so no two of them can disagree about which process + * produced them. + */ +export const otlpResource = (config: ServerConfig["Service"]) => ({ + serviceName: config.otlpServiceName, + ...(config.otelEnvironment.resource.serviceVersion === undefined + ? {} + : { serviceVersion: config.otelEnvironment.resource.serviceVersion }), + attributes: { + ...config.otelEnvironment.resource.attributes, + "service.runtime": "t3-server", + "service.mode": config.mode, + }, +}); + export const deriveServerPaths = Effect.fn(function* ( baseDir: ServerConfig["Service"]["baseDir"], devUrl: ServerConfig["Service"]["devUrl"], @@ -186,8 +205,10 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpMetricsExportIntervalMs: 10_000, + otlpLogsExportIntervalMs: 10_000, otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, cwd, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 17ab600257b..10ff8a43680 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -51,8 +51,10 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpMetricsExportIntervalMs: 10_000, + otlpLogsExportIntervalMs: 10_000, otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, cwd: process.cwd(), diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 4e5e2cfad64..59b11da0b10 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -30,10 +30,12 @@ export const ObservabilityLive = Layer.unwrap( ); } - // One variable can decline both signals, and saying so twice reads like - // two separate problems. + // One variable can decline every signal, and saying so three times reads + // like three separate problems. const declined = new Set( - [otel.traces.declined, otel.metrics.declined].filter((reason) => reason !== undefined), + [otel.traces.declined, otel.metrics.declined, otel.logs.declined].filter( + (reason) => reason !== undefined, + ), ); for (const reason of declined) { yield* Effect.logWarning(reason); @@ -57,17 +59,7 @@ export const ObservabilityLive = Layer.unwrap( ); } - const otlpResource = { - serviceName: config.otlpServiceName, - ...(otel.resource.serviceVersion === undefined - ? {} - : { serviceVersion: otel.resource.serviceVersion }), - attributes: { - ...otel.resource.attributes, - "service.runtime": "t3-server", - "service.mode": config.mode, - }, - }; + const otlpResource = ServerConfig.otlpResource(config); const traceReferencesLayer = Layer.mergeAll( Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), diff --git a/apps/server/src/observability/OtelEnvironment.test.ts b/apps/server/src/observability/OtelEnvironment.test.ts index 2e4c2baf98a..d217d210ebe 100644 --- a/apps/server/src/observability/OtelEnvironment.test.ts +++ b/apps/server/src/observability/OtelEnvironment.test.ts @@ -14,6 +14,7 @@ describe("OtelEnvironment", () => { const resolved = yield* OtelEnvironment.load.pipe(withEnv({})); assert.strictEqual(resolved.traces.settings, undefined); assert.strictEqual(resolved.metrics.settings, undefined); + assert.strictEqual(resolved.logs.settings, undefined); assert.strictEqual(resolved.disabled, false); }), ); @@ -28,6 +29,7 @@ describe("OtelEnvironment", () => { resolved.metrics.settings?.url, "https://collector.example.com/v1/metrics", ); + assert.strictEqual(resolved.logs.settings?.url, "https://collector.example.com/v1/logs"); }), ); @@ -196,6 +198,7 @@ describe("OtelEnvironment", () => { ); assert.strictEqual(resolved.traces.settings?.protocol, "http/protobuf"); assert.strictEqual(resolved.metrics.settings?.protocol, "http/protobuf"); + assert.strictEqual(resolved.logs.settings?.protocol, "http/protobuf"); }), ); @@ -219,11 +222,15 @@ describe("OtelEnvironment", () => { OTEL_BSP_SCHEDULE_DELAY: "2500", OTEL_BSP_MAX_EXPORT_BATCH_SIZE: "128", OTEL_METRIC_EXPORT_INTERVAL: "15000", + OTEL_BLRP_SCHEDULE_DELAY: "3500", + OTEL_BLRP_MAX_EXPORT_BATCH_SIZE: "64", }), ); assert.strictEqual(resolved.traces.settings?.exportIntervalMs, 2500); assert.strictEqual(resolved.traces.settings?.maxBatchSize, 128); assert.strictEqual(resolved.metrics.settings?.exportIntervalMs, 15000); + assert.strictEqual(resolved.logs.settings?.exportIntervalMs, 3500); + assert.strictEqual(resolved.logs.settings?.maxBatchSize, 64); }), ); @@ -262,6 +269,8 @@ describe("OtelEnvironment", () => { assert.strictEqual(resolved.traces.settings?.exportIntervalMs, 5000); assert.strictEqual(resolved.traces.settings?.maxBatchSize, 512); assert.strictEqual(resolved.metrics.settings?.exportIntervalMs, 60000); + assert.strictEqual(resolved.logs.settings?.exportIntervalMs, 1000); + assert.strictEqual(resolved.logs.settings?.maxBatchSize, 512); }), ); @@ -275,6 +284,7 @@ describe("OtelEnvironment", () => { ); assert.strictEqual(resolved.metrics.settings?.temporality, "delta"); assert.strictEqual(resolved.traces.settings?.temporality, undefined); + assert.strictEqual(resolved.logs.settings?.temporality, undefined); }), ); @@ -556,4 +566,95 @@ describe("OtelEnvironment", () => { assert.deepStrictEqual(resolved.warnings, []); }), ); + + it.effect("takes a log endpoint exactly as written", () => + Effect.gen(function* () { + const resolved = yield* OtelEnvironment.load.pipe( + withEnv({ + OTEL_EXPORTER_OTLP_ENDPOINT: "https://generic.example.com", + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: "https://logs.example.com/ingest", + }), + ); + assert.strictEqual(resolved.logs.settings?.url, "https://logs.example.com/ingest"); + assert.strictEqual(resolved.traces.settings?.url, "https://generic.example.com/v1/traces"); + }), + ); + + it.effect("honors the log signal turned off by name", () => + Effect.gen(function* () { + const resolved = yield* OtelEnvironment.load.pipe( + withEnv({ + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_LOGS_EXPORTER: "none", + }), + ); + assert.strictEqual(resolved.logs.settings, undefined); + assert.isDefined(resolved.traces.settings); + assert.isDefined(resolved.metrics.settings); + }), + ); + + it.effect("lets the log signal name its own wire format and headers", () => + Effect.gen(function* () { + const resolved = yield* OtelEnvironment.load.pipe( + withEnv({ + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json", + OTEL_EXPORTER_OTLP_LOGS_HEADERS: "x-scope=logs%2Fonly", + OTEL_EXPORTER_OTLP_HEADERS: "x-scope=everything", + }), + ); + assert.strictEqual(resolved.logs.settings?.protocol, "http/json"); + assert.deepStrictEqual(resolved.logs.settings?.headers, { "x-scope": "logs/only" }); + assert.strictEqual(resolved.traces.settings?.protocol, "http/protobuf"); + assert.deepStrictEqual(resolved.traces.settings?.headers, { "x-scope": "everything" }); + assert.deepStrictEqual(resolved.warnings, []); + }), + ); + + it.effect("declines only the log signal that asked for grpc", () => + Effect.gen(function* () { + const resolved = yield* OtelEnvironment.load.pipe( + withEnv({ + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc", + }), + ); + assert.strictEqual(resolved.logs.settings, undefined); + assert.include(resolved.logs.declined ?? "", "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL"); + assert.isDefined(resolved.traces.settings); + assert.isDefined(resolved.metrics.settings); + }), + ); + + it.effect("exports no log records once the SDK is disabled", () => + Effect.gen(function* () { + const resolved = yield* OtelEnvironment.load.pipe( + withEnv({ + OTEL_SDK_DISABLED: "true", + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: "https://logs.example.com/ingest", + }), + ); + assert.isTrue(resolved.disabled); + assert.strictEqual(resolved.logs.settings, undefined); + assert.strictEqual(resolved.logs.declined, undefined); + }), + ); + + it.effect("keeps a log record delay separate from the span one", () => + Effect.gen(function* () { + // The two are different variables with different defaults, and reading + // one for the other would export log records five times slower than the + // specification says to. + const resolved = yield* OtelEnvironment.load.pipe( + withEnv({ + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_BSP_SCHEDULE_DELAY: "9000", + }), + ); + assert.strictEqual(resolved.traces.settings?.exportIntervalMs, 9000); + assert.strictEqual(resolved.logs.settings?.exportIntervalMs, 1000); + }), + ); }); diff --git a/apps/server/src/observability/OtelEnvironment.ts b/apps/server/src/observability/OtelEnvironment.ts index 6f8b9de3d64..dba89474c07 100644 --- a/apps/server/src/observability/OtelEnvironment.ts +++ b/apps/server/src/observability/OtelEnvironment.ts @@ -10,8 +10,7 @@ * * Only the variables this server can act on are read. The exporter speaks * OTLP over HTTP, so `grpc` is declined loudly rather than answered with a - * body the endpoint cannot parse, and the log signal has no exporter here at - * all. + * body the endpoint cannot parse. * * Everything else the specification requires of an unusable value is a * warning followed by the default, never a refusal to start and never a @@ -23,6 +22,13 @@ import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +/** + * The signals this server exports. Each one is configured independently, and + * the specification spells every variable name with the signal in it, so the + * name is the thing the readers below are parameterized by. + */ +export type OtlpSignalName = "TRACES" | "METRICS" | "LOGS"; + /** The wire formats this server can produce. `grpc` is not one of them. */ export type OtlpProtocol = "http/json" | "http/protobuf"; @@ -42,7 +48,7 @@ export interface OtlpSignalSettings { readonly headers: Readonly> | undefined; readonly exportIntervalMs: number | undefined; readonly maxBatchSize: number | undefined; - /** Metrics only. Traces have no aggregation to prefer. */ + /** Metrics only. Spans and log records have no aggregation to prefer. */ readonly temporality: MetricsTemporality | undefined; } @@ -80,6 +86,7 @@ export interface OtelEnvironment { readonly warnings: ReadonlyArray; readonly traces: OtlpSignal; readonly metrics: OtlpSignal; + readonly logs: OtlpSignal; readonly resource: OtlpResourceSettings; } @@ -196,7 +203,7 @@ const optionalRecord = (name: string) => * The generic `OTEL_EXPORTER_OTLP_ENDPOINT` is a base, and the spec has each * signal append its own path to it. */ -const signalEndpoint = (signal: "TRACES" | "METRICS") => +const signalEndpoint = (signal: OtlpSignalName) => Effect.gen(function* () { const specific = yield* optionalString(`OTEL_EXPORTER_OTLP_${signal}_ENDPOINT`); if (specific !== undefined) { @@ -214,7 +221,7 @@ const signalEndpoint = (signal: "TRACES" | "METRICS") => * `OTEL__EXPORTER` is a list, and `otlp` is its default. A value that * names other exporters and not `otlp` is a deliberate "not this one". */ -const signalWantsOtlp = (signal: "TRACES" | "METRICS") => +const signalWantsOtlp = (signal: OtlpSignalName) => optionalString(`OTEL_${signal}_EXPORTER`).pipe( Effect.map((value) => { if (value === undefined) { @@ -233,12 +240,42 @@ const signalWantsOtlp = (signal: "TRACES" | "METRICS") => * keeps the numbers T3 Code has always used. */ const SPEC_DEFAULT_PROTOCOL = "http/protobuf" as const; -const SPEC_DEFAULT_SCHEDULE_DELAY_MS = 5_000; const SPEC_DEFAULT_MAX_EXPORT_BATCH_SIZE = 512; -const SPEC_DEFAULT_METRIC_EXPORT_INTERVAL_MS = 60_000; + +/** + * How often each signal drains, and how much it drains at once. The + * specification gives every signal its own variables and its own defaults: + * spans batch on `OTEL_BSP_*` every 5s, log records batch on `OTEL_BLRP_*` + * every 1s, and metrics have no batch size because a collection cycle already + * bounds itself. + */ +const SIGNAL_BATCHING = { + TRACES: { + scheduleDelay: "OTEL_BSP_SCHEDULE_DELAY", + defaultScheduleDelayMs: 5_000, + maxExportBatchSize: "OTEL_BSP_MAX_EXPORT_BATCH_SIZE", + }, + METRICS: { + scheduleDelay: "OTEL_METRIC_EXPORT_INTERVAL", + defaultScheduleDelayMs: 60_000, + maxExportBatchSize: undefined, + }, + LOGS: { + scheduleDelay: "OTEL_BLRP_SCHEDULE_DELAY", + defaultScheduleDelayMs: 1_000, + maxExportBatchSize: "OTEL_BLRP_MAX_EXPORT_BATCH_SIZE", + }, +} as const satisfies Record< + OtlpSignalName, + { + readonly scheduleDelay: string; + readonly defaultScheduleDelayMs: number; + readonly maxExportBatchSize: string | undefined; + } +>; const signalSettings = ( - signal: "TRACES" | "METRICS", + signal: OtlpSignalName, protocol: OtlpProtocol, temporality: MetricsTemporality | undefined, ) => @@ -251,22 +288,21 @@ const signalSettings = ( const specific = yield* optionalRecord(`OTEL_EXPORTER_OTLP_${signal}_HEADERS`); const generic = yield* optionalRecord("OTEL_EXPORTER_OTLP_HEADERS"); const headers = specific.value ?? generic.value; + const batching = SIGNAL_BATCHING[signal]; const exportIntervalMs = - signal === "TRACES" - ? ((yield* readInt("OTEL_BSP_SCHEDULE_DELAY", numbers)) ?? SPEC_DEFAULT_SCHEDULE_DELAY_MS) - : ((yield* readInt("OTEL_METRIC_EXPORT_INTERVAL", numbers)) ?? - SPEC_DEFAULT_METRIC_EXPORT_INTERVAL_MS); + (yield* readInt(batching.scheduleDelay, numbers)) ?? batching.defaultScheduleDelayMs; + const maxBatchSize = + batching.maxExportBatchSize === undefined + ? undefined + : ((yield* readInt(batching.maxExportBatchSize, numbers)) ?? + SPEC_DEFAULT_MAX_EXPORT_BATCH_SIZE); return { value: { url, protocol, headers, exportIntervalMs, - maxBatchSize: - signal === "TRACES" - ? ((yield* readInt("OTEL_BSP_MAX_EXPORT_BATCH_SIZE", numbers)) ?? - SPEC_DEFAULT_MAX_EXPORT_BATCH_SIZE) - : undefined, + maxBatchSize, temporality: signal === "METRICS" ? temporality : undefined, }, warnings: [...specific.warnings, ...generic.warnings, ...numbers], @@ -282,6 +318,7 @@ interface SignalProtocol { interface ProtocolDecision { readonly traces: SignalProtocol; readonly metrics: SignalProtocol; + readonly logs: SignalProtocol; readonly warnings: ReadonlyArray; } @@ -297,7 +334,7 @@ interface ProtocolDecision { * where traces go. A value that is not a protocol at all is a typo, and the * specification is explicit that those get a warning and the default. * - * Each signal builds its own serializer, so the two are answered separately + * Each signal builds its own serializer, so all three are answered separately * and are free to disagree. */ const resolveProtocol = Effect.gen(function* () { @@ -318,6 +355,7 @@ const resolveProtocol = Effect.gen(function* () { const generic = yield* read("OTEL_EXPORTER_OTLP_PROTOCOL"); const traces = (yield* read("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL")) ?? generic; const metrics = (yield* read("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL")) ?? generic; + const logs = (yield* read("OTEL_EXPORTER_OTLP_LOGS_PROTOCOL")) ?? generic; const decide = (named: typeof generic): SignalProtocol => named === undefined @@ -329,7 +367,12 @@ const resolveProtocol = Effect.gen(function* () { } : { protocol: named.value, declined: undefined }; - return { traces: decide(traces), metrics: decide(metrics), warnings } satisfies ProtocolDecision; + return { + traces: decide(traces), + metrics: decide(metrics), + logs: decide(logs), + warnings, + } satisfies ProtocolDecision; }); /** @@ -395,10 +438,13 @@ export const load: Effect.Effect = Effect.gen(function* () { const metrics = disabled ? { value: undefined, warnings: [] } : yield* signalSettings("METRICS", protocolDecision.metrics.protocol, temporality.value); + const logs = disabled + ? { value: undefined, warnings: [] } + : yield* signalSettings("LOGS", protocolDecision.logs.protocol, undefined); return { disabled, - // Both signals read the generic `OTEL_EXPORTER_OTLP_*` variables, so one - // bad value arrives here twice and would be logged twice. + // Every signal reads the generic `OTEL_EXPORTER_OTLP_*` variables, so one + // bad value arrives here once per signal and would be logged that often. warnings: [ ...new Set([ ...protocolDecision.warnings, @@ -406,6 +452,7 @@ export const load: Effect.Effect = Effect.gen(function* () { ...temporality.warnings, ...traces.warnings, ...metrics.warnings, + ...logs.warnings, ]), ], // `value` is set only for a signal that resolved an endpoint and asked for @@ -421,6 +468,10 @@ export const load: Effect.Effect = Effect.gen(function* () { settings: protocolDecision.metrics.declined === undefined ? metrics.value : undefined, declined: metrics.value === undefined ? undefined : protocolDecision.metrics.declined, }, + logs: { + settings: protocolDecision.logs.declined === undefined ? logs.value : undefined, + declined: logs.value === undefined ? undefined : protocolDecision.logs.declined, + }, resource: resource.value, }; }).pipe( @@ -431,6 +482,7 @@ export const load: Effect.Effect = Effect.gen(function* () { warnings: [], traces: { settings: undefined, declined: UNREADABLE }, metrics: { settings: undefined, declined: UNREADABLE }, + logs: { settings: undefined, declined: UNREADABLE }, resource: { serviceName: undefined, serviceVersion: undefined, attributes: {} }, }), ), @@ -446,5 +498,6 @@ export const none: OtelEnvironment = { warnings: [], traces: noSignal, metrics: noSignal, + logs: noSignal, resource: { serviceName: undefined, serviceVersion: undefined, attributes: {} }, }; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5f3e2ebe72c..2b2fa7f63c1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -442,8 +442,10 @@ const buildAppUnderTest = (options?: { traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpMetricsExportIntervalMs: 10_000, + otlpLogsExportIntervalMs: 10_000, otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, mode: "desktop", diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts new file mode 100644 index 00000000000..d2adbf06f8c --- /dev/null +++ b/apps/server/src/serverLogger.test.ts @@ -0,0 +1,148 @@ +import * as NodePath from "@effect/platform-node/NodePath"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as ServerConfig from "./config.ts"; +import * as OtelEnvironment from "./observability/OtelEnvironment.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 baseDir = "/tmp/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, + otlpMetricsExportIntervalMs: 10_000, + otlpLogsExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, + 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; + }); + +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 log signal asked for", () => + Effect.gen(function* () { + const requests = yield* logThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + otelEnvironment: { + ...OtelEnvironment.none, + logs: { + settings: { + url: "https://collector.example.com/v1/logs", + protocol: "http/protobuf", + headers: { "x-scope": "logs" }, + exportIntervalMs: 1_000, + maxBatchSize: 512, + temporality: undefined, + }, + declined: undefined, + }, + }, + }); + + assert.lengthOf(requests, 1); + assert.strictEqual(requests[0]?.headers["x-scope"], "logs"); + assert.strictEqual(requests[0]?.headers["content-type"], "application/x-protobuf"); + }), + ); +}); diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index a7cb1d6a26e..509d5e3f352 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,16 +1,51 @@ 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 * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization"; -import { ServerConfig } from "./config.ts"; +import * as ServerConfig from "./config.ts"; +/** + * Every logger the server installs, built in one `Logger.layer` call because + * that call writes the whole set at once. A second layer that also installs a + * logger either replaces this set or merges with the one the fiber had before + * either layer ran, depending on merge order, so the OTLP log exporter belongs + * here next to the console and tracer loggers rather than beside them in the + * observability layer. + */ export const ServerLoggerLive = Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; + const settings = config.otelEnvironment.logs.settings; + const otlpLogger = + config.otlpLogsUrl === undefined + ? undefined + : OtlpLogger.make({ + url: config.otlpLogsUrl, + exportInterval: `${config.otlpLogsExportIntervalMs} millis`, + resource: ServerConfig.otlpResource(config), + ...(settings?.headers === undefined ? {} : { headers: settings.headers }), + ...(settings?.maxBatchSize === undefined ? {} : { maxBatchSize: settings.maxBatchSize }), + }); + const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel); - const loggerLayer = Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { - mergeWithExisting: false, - }); + const loggerLayer = Logger.layer( + [ + Logger.consolePretty(), + Logger.tracerLogger, + ...(otlpLogger === undefined ? [] : [otlpLogger]), + ], + { mergeWithExisting: false }, + ).pipe( + Layer.provide(OtlpExporter.layerFlusher), + Layer.provide( + settings?.protocol === "http/protobuf" + ? OtlpSerialization.layerProtobuf + : OtlpSerialization.layerJson, + ), + ); 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 1708cba380a..df824081eb0 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -604,6 +604,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 ", }, }); @@ -611,6 +612,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 74d664afa58..df745de61d7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -731,6 +731,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/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 5c715eb4eb2..e8fc107b620 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -161,10 +161,25 @@ describe("formatDiagnosticsDescription", () => { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsEnabled: true, otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsEnabled: false, }), ).toBe("Local trace file. Exporting OTEL to http://localhost:4318/v1/{traces,metrics}."); }); + it("collapses all three signals when one collector answers them", () => { + expect( + formatDiagnosticsDescription({ + localTracingEnabled: true, + otlpTracesEnabled: true, + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsEnabled: true, + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsEnabled: true, + otlpLogsUrl: "http://localhost:4318/v1/logs", + }), + ).toBe("Local trace file. Exporting OTEL to http://localhost:4318/v1/{traces,metrics,logs}."); + }); + it("keeps separate trace and metric URLs when their base paths differ", () => { expect( formatDiagnosticsDescription({ @@ -173,18 +188,48 @@ describe("formatDiagnosticsDescription", () => { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsEnabled: true, otlpMetricsUrl: "http://localhost:9000/v1/metrics", + otlpLogsEnabled: false, }), ).toBe( "Local trace file. Exporting OTEL traces to http://localhost:4318/v1/traces and metrics to http://localhost:9000/v1/metrics.", ); }); + it("spells out all three signals when one of them went somewhere else", () => { + expect( + formatDiagnosticsDescription({ + localTracingEnabled: true, + otlpTracesEnabled: true, + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsEnabled: true, + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsEnabled: true, + otlpLogsUrl: "http://localhost:9000/v1/logs", + }), + ).toBe( + "Local trace file. Exporting OTEL traces to http://localhost:4318/v1/traces, metrics to http://localhost:4318/v1/metrics, and logs to http://localhost:9000/v1/logs.", + ); + }); + + it("names the one enabled signal rather than collapsing it", () => { + expect( + formatDiagnosticsDescription({ + localTracingEnabled: false, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + otlpLogsEnabled: true, + otlpLogsUrl: "http://localhost:4318/v1/logs", + }), + ).toBe("Terminal logs only. Exporting OTEL logs to http://localhost:4318/v1/logs."); + }); + it("omits OTEL text when no exporter is enabled", () => { expect( formatDiagnosticsDescription({ localTracingEnabled: true, otlpTracesEnabled: false, otlpMetricsEnabled: false, + otlpLogsEnabled: false, }), ).toBe("Local trace file."); }); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index a5d5d995849..9bdcae7b29f 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -194,23 +194,40 @@ export function backgroundActivitySharedPolicySettings( }; } -function collapseOtelSignalsUrl(input: { - readonly tracesUrl: string; - readonly metricsUrl: string; -}): string | null { - const tracesSuffix = "/traces"; - const metricsSuffix = "/metrics"; - if (!input.tracesUrl.endsWith(tracesSuffix) || !input.metricsUrl.endsWith(metricsSuffix)) { +interface OtelSignalExport { + readonly signal: string; + readonly url: string; +} + +/** + * One collector normally answers every signal at paths that differ only in the + * signal name, and reading three near-identical URLs to spot that is work. A + * signal pointed anywhere else keeps its own URL so it stays visible. + */ +function collapseOtelSignalsUrl(exports: ReadonlyArray): string | null { + if (exports.length < 2) { return null; } - const tracesBase = input.tracesUrl.slice(0, -tracesSuffix.length); - const metricsBase = input.metricsUrl.slice(0, -metricsSuffix.length); - if (tracesBase !== metricsBase) { + const bases = exports.map((entry) => + entry.url.endsWith(`/${entry.signal}`) + ? entry.url.slice(0, -(entry.signal.length + 1)) + : undefined, + ); + const [base] = bases; + if (base === undefined || bases.some((candidate) => candidate !== base)) { return null; } - return `${tracesBase}/{traces,metrics}`; + return `${base}/{${exports.map((entry) => entry.signal).join(",")}}`; +} + +function formatOtelSignalList(exports: ReadonlyArray): string { + const parts = exports.map((entry) => `${entry.signal} to ${entry.url}`); + if (parts.length < 3) { + return parts.join(" and "); + } + return `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]}`; } export function formatDiagnosticsDescription(input: { @@ -219,27 +236,26 @@ export function formatDiagnosticsDescription(input: { readonly otlpTracesUrl?: string | undefined; readonly otlpMetricsEnabled: boolean; readonly otlpMetricsUrl?: string | undefined; + readonly otlpLogsEnabled: boolean; + readonly otlpLogsUrl?: string | undefined; }): string { const mode = input.localTracingEnabled ? "Local trace file" : "Terminal logs only"; - const tracesUrl = input.otlpTracesEnabled ? input.otlpTracesUrl : undefined; - const metricsUrl = input.otlpMetricsEnabled ? input.otlpMetricsUrl : undefined; - - if (tracesUrl && metricsUrl) { - const collapsedUrl = collapseOtelSignalsUrl({ tracesUrl, metricsUrl }); - return collapsedUrl - ? `${mode}. Exporting OTEL to ${collapsedUrl}.` - : `${mode}. Exporting OTEL traces to ${tracesUrl} and metrics to ${metricsUrl}.`; - } - - if (tracesUrl) { - return `${mode}. Exporting OTEL traces to ${tracesUrl}.`; - } + const exports = [ + { signal: "traces", url: input.otlpTracesEnabled ? input.otlpTracesUrl : undefined }, + { signal: "metrics", url: input.otlpMetricsEnabled ? input.otlpMetricsUrl : undefined }, + { signal: "logs", url: input.otlpLogsEnabled ? input.otlpLogsUrl : undefined }, + ].flatMap((entry) => + entry.url ? [{ signal: entry.signal, url: entry.url }] : [], + ); - if (metricsUrl) { - return `${mode}. Exporting OTEL metrics to ${metricsUrl}.`; + if (exports.length === 0) { + return `${mode}.`; } - return `${mode}.`; + const collapsedUrl = collapseOtelSignalsUrl(exports); + return collapsedUrl + ? `${mode}. Exporting OTEL to ${collapsedUrl}.` + : `${mode}. Exporting OTEL ${formatOtelSignalList(exports)}.`; } export function buildProviderInstanceUpdatePatch(input: { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9539f95914c..c6517333be0 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1801,6 +1801,8 @@ export function GeneralSettingsPanel() { otlpTracesUrl: observability?.otlpTracesUrl, otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false, otlpMetricsUrl: observability?.otlpMetricsUrl, + otlpLogsEnabled: observability?.otlpLogsEnabled ?? false, + otlpLogsUrl: observability?.otlpLogsUrl, }); const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); diff --git a/docs/fork/0020-server-logs-reach-your-collector.md b/docs/fork/0020-server-logs-reach-your-collector.md new file mode 100644 index 00000000000..c0e233ac427 --- /dev/null +++ b/docs/fork/0020-server-logs-reach-your-collector.md @@ -0,0 +1,63 @@ +# 0020: Server logs reach your collector + +- PR: [TrogonStack/t3code#33](https://github.com/TrogonStack/t3code/pull/33) +- Status: active + +## What you can do now + +- Read T3 Code's server logs where you already read everything else. Log + records leave for your collector as OTLP, so a session that misbehaved can be + read next to the spans and metrics it produced instead of only in a file on + the machine that produced it. +- Configure the log signal exactly the way you configure the other two. The + standard `OTEL_EXPORTER_OTLP_LOGS_*` variables, the `T3CODE_OTLP_LOGS_URL` + name, the desktop bootstrap envelope, and Settings all reach it, in the same + order of precedence traces and metrics already use. +- Send one signal somewhere the others do not go, or turn one off on its own. + A log endpoint with its own address, wire format, or credentials is honored + without disturbing spans, and `OTEL_LOGS_EXPORTER=none` stops log export + while leaving the rest of your telemetry alone. +- Batch log records on their own schedule. `OTEL_BLRP_SCHEDULE_DELAY` and + `OTEL_BLRP_MAX_EXPORT_BATCH_SIZE` are the knobs the specification defines for + logs, so a delay meant for spans no longer decides how promptly a log record + arrives. +- See which signals are actually exporting. Settings names the log endpoint + next to the trace and metric ones, so a collector that is receiving two of + the three is visible rather than something you discover from an empty + dashboard. +- Keep the local log file. Nothing about the export changes what is written to + disk, so a machine with no collector behaves as it always has. + +## Why + +Traces and metrics could reach a collector and logs could not, which made the +one signal people reach for first the one signal T3 Code kept to itself. A +maintainer debugging a remote environment had spans showing that a turn took +too long and no way to read what the server said while it happened, short of +asking someone to find a file and paste it. + +Partial coverage is also the confusing kind of gap. Someone who exports +`OTEL_EXPORTER_OTLP_ENDPOINT` gets two signals and no indication that the third +was dropped, and a variable the rest of their fleet honors reasonably looks +honored here too. OpenTelemetry defines three signals; supporting two of them is +a bug in the same way that honoring a variable in some processes and not others +is a bug. + +Full parity is deliberate rather than incidental. A log signal that only read +the environment, or that ignored its own batching variables and borrowed the +span schedule, would be a second thing to learn instead of one less thing to +work around. + +## Upstream considerations + +This belongs upstream and is not fork-specific. It completes work upstream +already started, so the argument for it is the same argument that carried +traces and metrics. + +The rebase burden is concentrated in one place worth knowing about. Every +logger the server installs has to be declared together, because installing a +logger replaces the whole set rather than adding to it, so the OTLP log exporter +sits beside the console and tracer loggers rather than in the observability +layer with its sibling signals. A sync that adds another logger must add it to +that same set; a second layer that installs one independently silently drops +whichever set loses the merge. diff --git a/docs/fork/README.md b/docs/fork/README.md index b3eab354410..7b249f64e04 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -55,3 +55,5 @@ Each entry uses these sections: active, [#31](https://github.com/TrogonStack/t3code/pull/31) - **0019** [The checks badge answers the keyboard](./0019-the-checks-badge-answers-the-keyboard.md) active, [#32](https://github.com/TrogonStack/t3code/pull/32) +- **0020** [Server logs reach your collector](./0020-server-logs-reach-your-collector.md) + active, [#33](https://github.com/TrogonStack/t3code/pull/33) diff --git a/docs/operations/observability.md b/docs/operations/observability.md index cd0c74e87f8..d5658758e2c 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 @@ -16,14 +16,18 @@ stdout/stderr at `~/.t3/ssh-launch//server.log`. ### Logs -Logs are human-facing: +Every log the server writes goes to stdout for humans: - destination: stdout - format: `Logger.consolePretty()` - normal local persistence: none - SSH-managed launch persistence: `~/.t3/ssh-launch//server.log` -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. +When OTLP logs are configured the same records are also exported as OTLP log records, batched and +carrying the trace and span id of whatever was running, so a log line in the backend links back to +the span that produced it. + +If you want a log message to show up in the local trace file, emit it inside an active span with `Effect.log...`. `Logger.tracerLogger` will attach it as a span event. ### Traces @@ -68,7 +72,7 @@ Provider event NDJSON files still exist for provider runtime streams. Those are There are two useful modes: - local-only: stdout + local `server.trace.ndjson` -- full local observability: stdout + local trace file + OTLP export to Grafana/Tempo/Prometheus +- full local observability: stdout + local trace file + OTLP export to Grafana/Tempo/Prometheus/Loki The local trace file is always on. OTLP export is opt-in. @@ -115,6 +119,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 ``` @@ -154,6 +159,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 \ "/Applications/T3 Code.app/Contents/MacOS/T3 Code" ``` @@ -163,6 +169,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 ``` @@ -183,10 +190,9 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 export OTEL_SERVICE_NAME=t3-local ``` -The base endpoint is a base, not a full URL: traces go to `/v1/traces` and metrics to -`/v1/metrics`, exactly as the specification says. Set -`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` or `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` when a signal needs a -full URL of its own. +The base endpoint is a base, not a full URL: traces go to `/v1/traces`, metrics to +`/v1/metrics`, and log records to `/v1/logs`, exactly as the specification says. +Set `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` when a signal needs a full URL of its own. Ambient `OTEL_*` variables turn export on by themselves. A work collector in your shell profile means T3 Code exports to it, so use `OTEL_SDK_DISABLED=true` if that is not what you want. @@ -203,29 +209,29 @@ For each signal, the first source that names its endpoint wins: Whichever source wins takes the whole signal, not just the URL. Traces sent to a `T3CODE_OTLP_TRACES_URL` endpoint keep T3 Code's own wire format, headers, batching, and export interval even when `OTEL_*` variables are set, because those variables describe the collector they -named rather than this one. `T3CODE_OTLP_EXPORT_INTERVAL_MS` is the exception, and applies to both -signals wherever they go. +named rather than this one. `T3CODE_OTLP_EXPORT_INTERVAL_MS` is the exception, and applies to every +signal wherever it goes. -The two signals are resolved separately, so traces can come from one source and metrics from -another. +The three signals are resolved separately, so traces can come from one source and metrics or logs +from another. `OTEL_SDK_DISABLED=true` outranks all four and stops every export, including one configured through Settings. #### What Is Read -| Variable | Effect | -| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| `OTEL_SDK_DISABLED` | Stops all export | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base URL for both signals | -| `OTEL_EXPORTER_OTLP_{TRACES,METRICS}_ENDPOINT` | Full URL for one signal | -| `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_EXPORTER_OTLP_{TRACES,METRICS}_HEADERS` | Export headers, per signal overriding the shared ones | -| `OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_EXPORTER_OTLP_{TRACES,METRICS}_PROTOCOL` | `http/protobuf` (default) or `http/json` | -| `OTEL_{TRACES,METRICS}_EXPORTER` | A list; the signal is exported when it contains `otlp`, which is the default | -| `OTEL_SERVICE_NAME`, `OTEL_SERVICE_VERSION`, `OTEL_RESOURCE_ATTRIBUTES` | Resource identity attached to every span and metric | -| `OTEL_BSP_SCHEDULE_DELAY`, `OTEL_METRIC_EXPORT_INTERVAL` | Export interval | -| `OTEL_BSP_MAX_EXPORT_BATCH_SIZE` | Spans per batch | -| `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` | `cumulative` or `delta` | +| Variable | Effect | +| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | +| `OTEL_SDK_DISABLED` | Stops all export | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base URL for every signal | +| `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` | Full URL for one signal | +| `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` | Export headers, per signal overriding the shared ones | +| `OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_PROTOCOL` | `http/protobuf` (default) or `http/json` | +| `OTEL_{TRACES,METRICS,LOGS}_EXPORTER` | A list; the signal is exported when it contains `otlp`, which is the default | +| `OTEL_SERVICE_NAME`, `OTEL_SERVICE_VERSION`, `OTEL_RESOURCE_ATTRIBUTES` | Resource identity attached to every span, metric, and log record | +| `OTEL_BSP_SCHEDULE_DELAY`, `OTEL_METRIC_EXPORT_INTERVAL`, `OTEL_BLRP_SCHEDULE_DELAY` | Export interval, one per signal | +| `OTEL_BSP_MAX_EXPORT_BATCH_SIZE`, `OTEL_BLRP_MAX_EXPORT_BATCH_SIZE` | Spans per batch, log records per batch | +| `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` | `cumulative` or `delta` | The wire format defaults to `http/protobuf` when the endpoint came from `OTEL_*`, matching the specification, and stays `http/json` for a `T3CODE_OTLP_*` setup that never mentioned a protocol. @@ -252,7 +258,7 @@ Not everything in the specification is implemented. These are the ones worth kno `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` are ignored. A collector that requires mutual TLS needs a proxy in front of it. - **No export timeouts.** `OTEL_EXPORTER_OTLP_TIMEOUT`, - `OTEL_EXPORTER_OTLP_{TRACES,METRICS}_TIMEOUT`, and `OTEL_METRIC_EXPORT_TIMEOUT` are per-request + `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_TIMEOUT`, and `OTEL_METRIC_EXPORT_TIMEOUT` are per-request deadlines, and this exporter has no per-request knob, so they are ignored. Spending them on the shutdown flush instead would be the wrong meaning and would let a generous collector timeout hold the server open on every restart. @@ -270,9 +276,9 @@ Not everything in the specification is implemented. These are the ones worth kno the exporter library reads it too. `OTEL_RESOURCE_ATTRIBUTES=service.version=...` is the portable spelling. -Everything else not listed above is ignored, including the log signal, `OTEL_BSP_MAX_QUEUE_SIZE`, -`OTEL_BSP_EXPORT_TIMEOUT`, sampler variables, propagator variables, and the attribute and span -limit variables. +Everything else not listed above is ignored, including `OTEL_BSP_MAX_QUEUE_SIZE`, +`OTEL_BLRP_MAX_QUEUE_SIZE`, `OTEL_BSP_EXPORT_TIMEOUT`, `OTEL_BLRP_EXPORT_TIMEOUT`, sampler +variables, propagator variables, and the attribute and span limit variables. #### When A Value Cannot Be Used @@ -298,12 +304,14 @@ error a wrong one would, which reads like a bad token instead of a bad variable. These variables configure a signal only when they also supplied its endpoint. A `T3CODE_OTLP_*` name, the desktop bootstrap envelope, or Settings winning the URL takes the whole signal with it, so an ambient `OTEL_EXPORTER_OTLP_ENDPOINT` cannot reach in and change the wire format, headers, or -batching of an export it did not point anywhere. Traces and metrics are answered separately -throughout, so `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` applies to metrics alone and leaves traces as -they were. +batching of an export it did not point anywhere. Traces, metrics, and logs are answered separately +throughout, so `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` applies to metrics alone and leaves traces and +logs as they were. Once these variables are the ones configuring the exporter, the specification's own defaults apply: -`OTEL_BSP_SCHEDULE_DELAY` 5s, `OTEL_METRIC_EXPORT_INTERVAL` 60s, and `OTEL_BSP_MAX_EXPORT_BATCH_SIZE` 512. A `T3CODE_OTLP_*` setup keeps the numbers T3 Code has always used. +`OTEL_BSP_SCHEDULE_DELAY` 5s, `OTEL_METRIC_EXPORT_INTERVAL` 60s, `OTEL_BLRP_SCHEDULE_DELAY` 1s, and +`OTEL_BSP_MAX_EXPORT_BATCH_SIZE` and `OTEL_BLRP_MAX_EXPORT_BATCH_SIZE` 512 each. A `T3CODE_OTLP_*` +setup keeps the numbers T3 Code has always used. ## How To Use Traces And Metrics To Debug The Server diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 0af5850bf6c..a0adb69d93a 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -129,6 +129,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 f4d1a092786..bc3558aa296 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 23e4a43bf5c..3c27b6dd3ba 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ServerConfig, + ServerObservability, ServerProvider, ServerProviders, ServerUpsertKeybindingResult, @@ -12,6 +13,7 @@ const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders); const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors); +const decodeServerObservability = Schema.decodeUnknownSync(ServerObservability); const baseProviderSnapshot = { instanceId: "codex", @@ -132,6 +134,19 @@ describe("server config forward compatibility", () => { ]); }); + 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(); + }); + it("drops editor ids this build does not know", () => { const parsed = decodeAvailableEditors(["zed", "some-future-editor", "vscode"]); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f6218..87f120487c8 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -220,6 +220,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.test.ts b/packages/contracts/src/settings.test.ts index 46a4d25ac30..6794669b56f 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -295,6 +295,8 @@ describe("ServerSettingsPatch string normalization", () => { textGenerationModelSelection: { model: " gpt-5.4-mini " }, observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", + otlpMetricsUrl: " http://localhost:4318/v1/metrics ", + otlpLogsUrl: " http://localhost:4318/v1/logs ", }, providers: { codex: { @@ -315,6 +317,8 @@ describe("ServerSettingsPatch string normalization", () => { expect(patch.addProjectBaseDirectory).toBe("~/Development"); expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini"); expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); + expect(patch.observability?.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics"); + expect(patch.observability?.otlpLogsUrl).toBe("http://localhost:4318/v1/logs"); expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); expect(patch.providers?.codex?.homePath).toBe("~/.codex"); expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index aa197458b6e..95352debe2c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -531,6 +531,7 @@ export type OpenCodeSettings = typeof OpenCodeSettings.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; @@ -850,6 +851,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 baa84a4e1aa..b63b8834f24 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -32,11 +32,13 @@ 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", }); }); @@ -47,12 +49,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", }); }); @@ -60,6 +64,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 69fc9eaacbc..cde86972301 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -67,6 +67,7 @@ export function resolveSourceControlWriterModelSelection( export interface PersistedServerObservabilitySettings { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; + readonly otlpLogsUrl: string | undefined; } export function normalizePersistedServerSettingString( @@ -80,11 +81,13 @@ export 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), }; } @@ -95,7 +98,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(