From f40524c9b055080185c04ddd7456ed2ae7771e2a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 25 Sep 2026 22:01:08 -0400 Subject: [PATCH] refactor(observability): drop the fork's OTEL divergence now that upstream covers it Signed-off-by: Yordis Prieto --- apps/desktop/src/app/DesktopConfig.ts | 13 +- .../src/app/DesktopEnvironment.test.ts | 6 +- apps/desktop/src/app/DesktopEnvironment.ts | 14 +- .../src/app/DesktopObservability.test.ts | 96 +- apps/desktop/src/app/DesktopObservability.ts | 170 +-- .../desktop/src/app/DesktopOtlpExport.test.ts | 321 ---- apps/desktop/src/app/DesktopOtlpExport.ts | 162 -- .../DesktopBackendConfiguration.test.ts | 110 -- .../backend/DesktopBackendConfiguration.ts | 47 +- apps/server/src/bin.test.ts | 7 +- apps/server/src/cli/config.test.ts | 571 +------ apps/server/src/cli/config.ts | 104 +- apps/server/src/cli/pair.ts | 7 +- apps/server/src/config.ts | 41 +- .../src/environment/ServerEnvironment.test.ts | 7 +- .../src/observability/Layers/Observability.ts | 53 +- apps/server/src/server.test.ts | 15 +- apps/server/src/serverLogger.test.ts | 14 +- apps/server/src/serverLogger.ts | 3 +- .../settings/SettingsPanels.logic.test.ts | 45 - .../settings/SettingsPanels.logic.ts | 70 +- .../components/settings/SettingsPanels.tsx | 14 +- ...the-standard-otel-variables-are-honored.md | 91 -- ...22-the-desktop-app-reports-its-own-work.md | 63 - docs/fork/README.md | 4 - docs/operations/observability.md | 277 +--- packages/contracts/src/server.test.ts | 13 - packages/contracts/src/settings.test.ts | 4 - packages/shared/src/observability.ts | 19 + packages/shared/src/otelEnvironment.test.ts | 1343 +++++------------ packages/shared/src/otelEnvironment.ts | 1057 ++++--------- 31 files changed, 1038 insertions(+), 3723 deletions(-) delete mode 100644 apps/desktop/src/app/DesktopOtlpExport.test.ts delete mode 100644 apps/desktop/src/app/DesktopOtlpExport.ts delete mode 100644 docs/fork/0018-the-standard-otel-variables-are-honored.md delete mode 100644 docs/fork/0022-the-desktop-app-reports-its-own-work.md diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index b8c885754ecf..773ea24509cf 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -48,14 +48,13 @@ export const DesktopConfig = Config.all({ otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"), otlpMetricsUrl: trimmedString("T3CODE_OTLP_METRICS_URL"), otlpLogsUrl: trimmedString("T3CODE_OTLP_LOGS_URL"), - // Left as an Option rather than defaulted here: an unset variable is what - // lets each signal fall back to the interval the OpenTelemetry environment - // asked for, which the specification defines per signal. - otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(Config.option), - // Options for the same reason: unset is what lets the OpenTelemetry - // environment supply headers and wire format instead. + otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( + Config.withDefault(10_000), + ), otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(Config.option), - otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe(Config.option), + otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe( + Config.withDefault("http/json"), + ), appImagePath: trimmedString("APPIMAGE"), disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 9c220b72b7ae..b5aabf253d0f 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -90,7 +90,7 @@ describe("DesktopEnvironment", () => { 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.deepEqual(environment.otlpExportIntervalMs, Option.some(2500)); + assert.equal(environment.otlpExportIntervalMs, 2500); assert.deepEqual( environment.otlpHeaders, Option.some({ @@ -98,7 +98,7 @@ describe("DesktopEnvironment", () => { "x-tenant": "t3", }), ); - assert.deepEqual(environment.otlpProtocol, Option.some("http/protobuf")); + assert.equal(environment.otlpProtocol, "http/protobuf"); }), ); @@ -116,7 +116,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); - assert.deepEqual(environment.otlpProtocol, Option.none()); + assert.equal(environment.otlpProtocol, "http/json"); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 1ae98e1b77b9..beb09bd50a46 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -4,7 +4,6 @@ import type { DesktopRuntimeArch, DesktopRuntimeInfo, } from "@t3tools/contracts"; -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -77,16 +76,9 @@ export class DesktopEnvironment extends Context.Service< readonly otlpTracesUrl: Option.Option; readonly otlpMetricsUrl: Option.Option; readonly otlpLogsUrl: Option.Option; - readonly otlpExportIntervalMs: Option.Option; + readonly otlpExportIntervalMs: number; readonly otlpHeaders: Option.Option>; - readonly otlpProtocol: Option.Option; - /** - * What the standard `OTEL_*` variables asked for. The `T3CODE_OTLP_*` - * values above still win per signal; this is what the main process - * falls back to, and it carries the headers, wire format, resource, and - * batching that T3 Code has no names of its own for. - */ - readonly otelEnvironment: OtelEnvironment.OtelEnvironment; + readonly otlpProtocol: OtlpProtocol; readonly branding: DesktopAppBranding; readonly displayName: string; readonly appUserModelId: string; @@ -163,7 +155,6 @@ const make = Effect.fn("desktop.environment.make")(function* ( ): Effect.fn.Return { const path = yield* Path.Path; const config = yield* DesktopConfig.DesktopConfig; - const otelEnvironment = yield* OtelEnvironment.load; const homeDirectory = input.homeDirectory; const devServerUrl = config.devServerUrl; const isDevelopment = Option.isSome(devServerUrl); @@ -246,7 +237,6 @@ const make = Effect.fn("desktop.environment.make")(function* ( otlpExportIntervalMs: config.otlpExportIntervalMs, otlpHeaders: config.otlpHeaders, otlpProtocol: config.otlpProtocol, - otelEnvironment, branding, displayName, appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index f65cc6bd5959..e23d78aa2161 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -1,6 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -92,6 +93,9 @@ const collectorLayer = (requests: Array) => ), ); +// A developer's own OTEL_* variables would otherwise pick the endpoints. +const emptyEnv = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); + const encodeObservabilitySettingsFile = Schema.encodeSync( Schema.fromJsonString( Schema.Struct({ observability: Schema.Record(Schema.String, Schema.String) }), @@ -181,7 +185,7 @@ describe("DesktopObservability", () => { assert.isFalse(yield* fileSystem.exists(logPath)); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -259,7 +263,7 @@ describe("DesktopObservability", () => { ); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -299,7 +303,7 @@ describe("DesktopObservability", () => { assert.equal(records.at(-1)?.annotations.details, "code=1"); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -343,7 +347,7 @@ describe("DesktopObservability", () => { assert.isFalse(text.includes("y")); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -377,7 +381,7 @@ describe("DesktopObservability", () => { assert.equal(lines.length, 258); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -423,7 +427,7 @@ describe("DesktopObservability", () => { assert.lengthOf(record?.events ?? [], 0); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), ); }); @@ -436,9 +440,6 @@ describe("DesktopObservability", () => { }); const environmentLayer = makeEnvironmentLayer(baseDir, true, { T3CODE_OTLP_HEADERS: "x-scope=desktop", - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_HEADERS: "x-otel=desktop", - OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json", }); yield* writeObservabilitySettings(environmentLayer, { otlpLogsUrl: "https://settings.example.com/v1/logs", @@ -458,7 +459,21 @@ describe("DesktopObservability", () => { assert.strictEqual(request?.headers["content-type"], "application/json"); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_EXPORTER_OTLP_HEADERS: "x-otel=desktop", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json", + }, + }), + ), + ), + ), ); }); @@ -471,9 +486,6 @@ describe("DesktopObservability", () => { }); const environmentLayer = makeEnvironmentLayer(baseDir, true, { T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", - OTEL_SERVICE_NAME: "renamed", - OTEL_RESOURCE_ATTRIBUTES: - "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", }); yield* Effect.scoped( @@ -490,7 +502,21 @@ describe("DesktopObservability", () => { assert.notInclude(body, "renamed"); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_SERVICE_NAME: "renamed", + OTEL_RESOURCE_ATTRIBUTES: + "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", + }, + }), + ), + ), + ), ); }); @@ -503,8 +529,6 @@ describe("DesktopObservability", () => { }); const environmentLayer = makeEnvironmentLayer(baseDir, true, { T3CODE_OTLP_HEADERS: "x-scope=desktop", - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc", }); yield* writeObservabilitySettings(environmentLayer, { otlpLogsUrl: "https://settings.example.com/v1/logs", @@ -519,7 +543,20 @@ describe("DesktopObservability", () => { assert.lengthOf(requests, 0); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc", + }, + }), + ), + ), + ), ); }); @@ -532,7 +569,6 @@ describe("DesktopObservability", () => { }); const environmentLayer = makeEnvironmentLayer(baseDir, true, { T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", - OTEL_SDK_DISABLED: "1", }); yield* Effect.scoped( @@ -544,7 +580,13 @@ describe("DesktopObservability", () => { assert.include(requests[0]?.body ?? "", "OTEL_SDK_DISABLED=1 was read as false"); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "1" } })), + ), + ), ); }); @@ -585,7 +627,7 @@ describe("DesktopObservability", () => { ); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), ); }); @@ -609,7 +651,7 @@ describe("DesktopObservability", () => { assert.lengthOf(requests, 0); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), ); }); @@ -620,9 +662,7 @@ describe("DesktopObservability", () => { const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-observability-test-", }); - const environmentLayer = makeEnvironmentLayer(baseDir, true, { - OTEL_SDK_DISABLED: "true", - }); + const environmentLayer = makeEnvironmentLayer(baseDir); yield* writeObservabilitySettings(environmentLayer, { otlpTracesUrl: "https://settings.example.com/v1/traces", otlpLogsUrl: "https://settings.example.com/v1/logs", @@ -638,7 +678,13 @@ describe("DesktopObservability", () => { assert.lengthOf(requests, 0); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "true" } })), + ), + ), ); }); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 2d492afa7ac5..ce8233e56612 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -3,6 +3,7 @@ import { makeLocalFileTracer, makeTraceSink, otlpSerializationLayer, + type SignalExport, } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { @@ -28,18 +29,11 @@ import * as Tracer from "effect/Tracer"; import { OtlpExporter, OtlpLogger, OtlpTracer } from "effect/unstable/observability"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import { - type DesktopOtlpResource, - type DesktopOtlpSignal, - resolveDesktopOtlpExport, -} from "./DesktopOtlpExport.ts"; const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const DESKTOP_LOG_FILE_MAX_FILES = 10; const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; const DESKTOP_TRACE_BATCH_WINDOW_MS = 1_000; -/** What the main process calls itself, in the family with `t3code-server` and `t3code-web`. */ -const DESKTOP_SERVICE_NAME = "t3code-desktop"; const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES = 1024 * 1024; const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_CHUNKS = 256; @@ -357,34 +351,52 @@ const readPersistedObservabilitySettings: Effect.Effect< }); /** - * Read once for all three signals, so the main process cannot resolve traces - * against one revision of Settings and logs against another. + * Resolved as the server resolves them, with persisted Settings as the + * fallback. Settings is read once for every signal, so the main process + * cannot resolve traces against one revision of the file and logs against + * another. */ -const resolveOtlpExport = Effect.gen(function* () { +const resolveOtlpEndpoints = Effect.gen(function* () { + const otel = yield* OtelEnvironment.load; + if (otel.disabled) { + return { + traces: undefined, + metrics: undefined, + logs: undefined, + warnings: otel.warnings, + resourceAttributes: otel.resourceAttributes, + }; + } + const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedObservabilitySettings; - return resolveDesktopOtlpExport({ - otel: environment.otelEnvironment, - named: { - traces: Option.getOrUndefined(environment.otlpTracesUrl), - metrics: Option.getOrUndefined(environment.otlpMetricsUrl), - logs: Option.getOrUndefined(environment.otlpLogsUrl), - }, - persisted: { - traces: persisted.otlpTracesUrl, - metrics: persisted.otlpMetricsUrl, - logs: persisted.otlpLogsUrl, - }, - namedExportIntervalMs: Option.getOrUndefined(environment.otlpExportIntervalMs), - namedHeaders: Option.getOrUndefined(environment.otlpHeaders), - namedProtocol: Option.getOrUndefined(environment.otlpProtocol), - serviceName: DESKTOP_SERVICE_NAME, - runtimeAttributes: { - "service.namespace": "t3code", - "service.runtime": "desktop", - "service.mode": environment.isDevelopment ? "development" : "packaged", - }, - }); + const signalExport: SignalExport = { + protocol: environment.otlpProtocol, + headers: Option.getOrUndefined(environment.otlpHeaders), + exportIntervalMs: environment.otlpExportIntervalMs, + }; + return { + traces: OtelEnvironment.resolveSignalEndpoint( + otel, + "traces", + { url: Option.getOrUndefined(environment.otlpTracesUrl), export: signalExport }, + persisted.otlpTracesUrl, + ), + metrics: OtelEnvironment.resolveSignalEndpoint( + otel, + "metrics", + { url: Option.getOrUndefined(environment.otlpMetricsUrl), export: signalExport }, + persisted.otlpMetricsUrl, + ), + logs: OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + { url: Option.getOrUndefined(environment.otlpLogsUrl), export: signalExport }, + persisted.otlpLogsUrl, + ), + warnings: otel.warnings, + resourceAttributes: otel.resourceAttributes, + }; }); const writeDevelopmentConsoleOutput = ( @@ -604,25 +616,23 @@ const backendOutputLogFactoryLayer = Layer.effect( }), ); -const serializationFor = (signal: DesktopOtlpSignal) => otlpSerializationLayer(signal.protocol); - -const otlpResourceFor = (resource: DesktopOtlpResource) => ({ - serviceName: resource.serviceName, - ...(resource.serviceVersion === undefined ? {} : { serviceVersion: resource.serviceVersion }), - attributes: resource.attributes, -}); - /** - * Logs and traces for the main process, built together because they share one - * read of the environment and Settings, and because a process gets exactly one - * logger set. + * Logs and traces for the main process, assembled together because they share + * one read of the environment and Settings, and because a process gets exactly + * one logger set. */ const telemetryLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - const resolved = yield* resolveOtlpExport; - - const otlpResource = otlpResourceFor(resolved.resource); + const endpoints = yield* resolveOtlpEndpoints; + const resource = { + serviceName: "t3code-desktop", + attributes: { + "service.namespace": "t3code", + "service.runtime": "desktop", + "service.mode": environment.isDevelopment ? "development" : "packaged", + }, + }; // `Logger.layer` writes the whole logger set rather than adding to it, so // every logger the main process wants has to be named in this one call. @@ -636,24 +646,23 @@ const telemetryLayer = Layer.unwrap( // with its trace and span ids. Keeping both would export every in-span // message twice. const loggerLayer = Logger.layer( - resolved.logs.url === undefined + endpoints.logs === undefined ? [Logger.consolePretty(), Logger.tracerLogger] : [ Logger.consolePretty(), OtlpLogger.make({ - url: resolved.logs.url, - exportInterval: `${resolved.logs.exportIntervalMs} millis`, - resource: otlpResource, - ...(resolved.logs.headers === undefined ? {} : { headers: resolved.logs.headers }), - ...(resolved.logs.maxBatchSize === undefined - ? {} - : { maxBatchSize: resolved.logs.maxBatchSize }), + url: endpoints.logs.url, + exportInterval: `${endpoints.logs.export.exportIntervalMs} millis`, + headers: endpoints.logs.export.headers, + resource, }), ], { mergeWithExisting: false }, ).pipe( Layer.provide(OtlpExporter.layerFlusher), - Layer.provide(serializationFor(resolved.logs)), + Layer.provide( + otlpSerializationLayer(endpoints.logs?.export.protocol ?? environment.otlpProtocol), + ), ); const tracerLayer = Layer.unwrap( @@ -666,19 +675,14 @@ const telemetryLayer = Layer.unwrap( batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, }); const delegate = - resolved.traces.url === undefined + endpoints.traces === undefined ? undefined : yield* OtlpTracer.make({ - url: resolved.traces.url, - exportInterval: `${resolved.traces.exportIntervalMs} millis`, - resource: otlpResource, - ...(resolved.traces.headers === undefined - ? {} - : { headers: resolved.traces.headers }), - ...(resolved.traces.maxBatchSize === undefined - ? {} - : { maxBatchSize: resolved.traces.maxBatchSize }), - }); + url: endpoints.traces.url, + exportInterval: `${endpoints.traces.export.exportIntervalMs} millis`, + headers: endpoints.traces.export.headers, + resource, + }).pipe(Effect.provide(otlpSerializationLayer(endpoints.traces.export.protocol))); const tracer = yield* makeLocalFileTracer({ filePath: tracePath, maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, @@ -690,40 +694,32 @@ const telemetryLayer = Layer.unwrap( return Layer.succeed(Tracer.Tracer, tracer); }), - ).pipe( - Layer.provide(OtlpExporter.layerFlusher), - Layer.provide(serializationFor(resolved.traces)), - ); + ).pipe(Layer.provide(OtlpExporter.layerFlusher)); // Metrics stay off until the main process records one. `OtlpMetrics` - // exports on every interval even when the registry is empty, so wiring it - // up today would post an empty payload every interval to every collector - // the environment points at. Restore this when a desktop metric exists, - // and add it to the `Layer.mergeAll` below. + // exports on every interval even when the registry is empty, so wiring + // it up today would post an empty payload every ten seconds to any + // collector configured for the backend. Restore this when a desktop + // metric exists, and add it to the `Layer.mergeAll` below. // // const metricsLayer = - // resolved.metrics.url === undefined + // endpoints.metrics === undefined // ? Layer.empty // : OtlpMetrics.layer({ - // url: resolved.metrics.url, - // exportInterval: `${resolved.metrics.exportIntervalMs} millis`, - // resource: otlpResource, - // ...(resolved.metrics.headers === undefined - // ? {} - // : { headers: resolved.metrics.headers }), - // ...(resolved.metrics.temporality === undefined - // ? {} - // : { temporality: resolved.metrics.temporality }), - // }).pipe(Layer.provide(serializationFor(resolved.metrics))); + // url: endpoints.metrics.url, + // exportInterval: `${endpoints.metrics.export.exportIntervalMs} millis`, + // headers: endpoints.metrics.export.headers, + // resource, + // }).pipe(Layer.provide(otlpSerializationLayer(endpoints.metrics.export.protocol))); // Logged once the loggers above are installed, so the warnings use them. const otelWarningsLayer = Layer.effectDiscard( - Effect.forEach(resolved.warnings, (warning) => Effect.logWarning(warning)), + Effect.forEach(endpoints.warnings, (warning) => Effect.logWarning(warning)), ); return otelWarningsLayer.pipe( Layer.provideMerge(Layer.mergeAll(loggerLayer, tracerLayer)), - Layer.provide(OtelEnvironment.layerResourceAttributes(resolved.resource.attributes)), + Layer.provide(OtelEnvironment.layerResourceAttributes(endpoints.resourceAttributes)), ); }), ); diff --git a/apps/desktop/src/app/DesktopOtlpExport.test.ts b/apps/desktop/src/app/DesktopOtlpExport.test.ts deleted file mode 100644 index 285b30030906..000000000000 --- a/apps/desktop/src/app/DesktopOtlpExport.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; -import * as ConfigProvider from "effect/ConfigProvider"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { - DEFAULT_DESKTOP_EXPORT_INTERVAL_MS, - type DesktopOtlpEndpoints, - resolveDesktopOtlpExport, -} from "./DesktopOtlpExport.ts"; - -const noEndpoints: DesktopOtlpEndpoints = { - traces: undefined, - metrics: undefined, - logs: undefined, -}; - -const resolve = ( - env: Record, - overrides: { - readonly named?: Partial; - readonly persisted?: Partial; - readonly namedExportIntervalMs?: number; - readonly namedHeaders?: Readonly>; - readonly namedProtocol?: "http/json" | "http/protobuf"; - } = {}, -) => - OtelEnvironment.load.pipe( - Effect.provide(Layer.mergeAll(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))), - Effect.map((otel) => - resolveDesktopOtlpExport({ - otel, - named: { ...noEndpoints, ...overrides.named }, - persisted: { ...noEndpoints, ...overrides.persisted }, - namedExportIntervalMs: overrides.namedExportIntervalMs, - namedHeaders: overrides.namedHeaders, - namedProtocol: overrides.namedProtocol, - serviceName: "t3code-desktop", - runtimeAttributes: { "service.runtime": "desktop", "service.mode": "development" }, - }), - ), - ); - -describe("resolveDesktopOtlpExport", () => { - it.effect("exports nothing when neither T3 Code nor OpenTelemetry named an endpoint", () => - Effect.gen(function* () { - const resolved = yield* resolve({}); - assert.strictEqual(resolved.traces.url, undefined); - assert.strictEqual(resolved.metrics.url, undefined); - assert.strictEqual(resolved.logs.url, undefined); - assert.deepStrictEqual(resolved.warnings, []); - }), - ); - - it.effect("picks up all three signals from the generic OpenTelemetry endpoint", () => - Effect.gen(function* () { - const resolved = yield* resolve({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - }); - assert.strictEqual(resolved.traces.url, "https://collector.example.com/v1/traces"); - assert.strictEqual(resolved.metrics.url, "https://collector.example.com/v1/metrics"); - assert.strictEqual(resolved.logs.url, "https://collector.example.com/v1/logs"); - assert.strictEqual(resolved.traces.protocol, "http/protobuf"); - }), - ); - - it.effect("reads an exported endpoint before a stored one", () => - Effect.gen(function* () { - // Same order the server uses, because a machine's variables must not - // resolve differently in the two processes reading them. - const resolved = yield* resolve( - { OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com" }, - { - named: { logs: "http://localhost:4318/v1/logs" }, - persisted: { - traces: "http://stored.example.com/v1/traces", - metrics: "http://stored.example.com/v1/metrics", - logs: "http://stored.example.com/v1/logs", - }, - }, - ); - assert.strictEqual(resolved.traces.url, "https://collector.example.com/v1/traces"); - assert.strictEqual(resolved.traces.protocol, "http/protobuf"); - assert.strictEqual(resolved.metrics.url, "https://collector.example.com/v1/metrics"); - // T3 Code's own name outranks both, and keeps T3 Code's wire format. - assert.strictEqual(resolved.logs.url, "http://localhost:4318/v1/logs"); - assert.strictEqual(resolved.logs.protocol, "http/json"); - }), - ); - - it.effect("falls back to a stored endpoint for the signals nothing exported", () => - Effect.gen(function* () { - const resolved = yield* resolve( - { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://collector.example.com/v1/traces" }, - { persisted: { metrics: "http://stored.example.com/v1/metrics" } }, - ); - assert.strictEqual(resolved.traces.url, "https://collector.example.com/v1/traces"); - assert.strictEqual(resolved.metrics.url, "http://stored.example.com/v1/metrics"); - // A stored endpoint keeps the interval T3 Code has always used, because - // the variables that name one did not name this endpoint. - assert.strictEqual(resolved.metrics.exportIntervalMs, DEFAULT_DESKTOP_EXPORT_INTERVAL_MS); - assert.strictEqual(resolved.logs.url, undefined); - }), - ); - - it.effect("lets a named endpoint take the whole signal, not only its url", () => - Effect.gen(function* () { - const resolved = yield* resolve( - { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", - OTEL_EXPORTER_OTLP_HEADERS: "authorization=Bearer%20token", - }, - { named: { traces: "http://127.0.0.1:4318/v1/traces" } }, - ); - - assert.strictEqual(resolved.traces.url, "http://127.0.0.1:4318/v1/traces"); - assert.strictEqual(resolved.traces.protocol, "http/json"); - assert.strictEqual(resolved.traces.headers, undefined); - assert.strictEqual(resolved.traces.exportIntervalMs, DEFAULT_DESKTOP_EXPORT_INTERVAL_MS); - - assert.strictEqual(resolved.metrics.url, "https://collector.example.com/v1/metrics"); - assert.strictEqual(resolved.metrics.protocol, "http/protobuf"); - assert.deepStrictEqual(resolved.metrics.headers, { authorization: "Bearer token" }); - }), - ); - - it.effect("gives a named endpoint T3 Code's own headers and wire format", () => - Effect.gen(function* () { - const resolved = yield* resolve( - { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_PROTOCOL: "http/json", - OTEL_EXPORTER_OTLP_HEADERS: "authorization=Bearer%20ambient", - }, - { - named: { traces: "http://127.0.0.1:4318/v1/traces" }, - namedHeaders: { authorization: "Bearer named" }, - namedProtocol: "http/protobuf", - }, - ); - - assert.strictEqual(resolved.traces.protocol, "http/protobuf"); - assert.deepStrictEqual(resolved.traces.headers, { authorization: "Bearer named" }); - - // The endpoint these variables named keeps what they said about it. - assert.strictEqual(resolved.metrics.protocol, "http/json"); - assert.deepStrictEqual(resolved.metrics.headers, { authorization: "Bearer ambient" }); - }), - ); - - it.effect("stops every export when the OpenTelemetry SDK is disabled", () => - Effect.gen(function* () { - const resolved = yield* resolve( - { - OTEL_SDK_DISABLED: "true", - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - }, - { named: { traces: "http://127.0.0.1:4318/v1/traces" } }, - ); - assert.strictEqual(resolved.traces.url, undefined); - assert.strictEqual(resolved.metrics.url, undefined); - assert.strictEqual(resolved.logs.url, undefined); - assert.include(resolved.warnings.join("\n"), "OTEL_SDK_DISABLED"); - }), - ); - - it.effect("keeps exporting when T3 Code's own name says to", () => - Effect.gen(function* () { - const resolved = yield* resolve( - { - T3CODE_OTEL_SDK_DISABLED: "false", - OTEL_SDK_DISABLED: "true", - }, - { named: { traces: "http://127.0.0.1:4318/v1/traces" } }, - ); - assert.strictEqual(resolved.traces.url, "http://127.0.0.1:4318/v1/traces"); - assert.deepStrictEqual(resolved.warnings, []); - }), - ); - - it.effect("declines only the signal that asked for a protocol T3 Code cannot speak", () => - Effect.gen(function* () { - const resolved = yield* resolve({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "grpc", - }); - assert.strictEqual(resolved.traces.url, undefined); - assert.strictEqual(resolved.metrics.url, "https://collector.example.com/v1/metrics"); - assert.strictEqual(resolved.logs.url, "https://collector.example.com/v1/logs"); - assert.lengthOf(resolved.warnings, 1); - assert.include(resolved.warnings[0] ?? "", "grpc"); - }), - ); - - it.effect("keeps exporting a signal whose grpc endpoint another variable overrode", () => - Effect.gen(function* () { - const resolved = yield* resolve( - { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "grpc", - }, - { named: { traces: "http://127.0.0.1:4318/v1/traces" } }, - ); - assert.strictEqual(resolved.traces.url, "http://127.0.0.1:4318/v1/traces"); - assert.deepStrictEqual(resolved.warnings, []); - }), - ); - - it.effect("honors one T3 Code interval across every signal it named", () => - Effect.gen(function* () { - const resolved = yield* resolve( - {}, - { - named: { - traces: "http://127.0.0.1:4318/v1/traces", - metrics: "http://127.0.0.1:4318/v1/metrics", - logs: "http://127.0.0.1:4318/v1/logs", - }, - namedExportIntervalMs: 2500, - }, - ); - assert.strictEqual(resolved.traces.exportIntervalMs, 2500); - assert.strictEqual(resolved.metrics.exportIntervalMs, 2500); - assert.strictEqual(resolved.logs.exportIntervalMs, 2500); - }), - ); - - it.effect("keeps a stored endpoint from re-enabling a signal turned off by name", () => - Effect.gen(function* () { - // The main process reads the same sources in the same order as the - // server, so a signal an exported variable switched off must not come - // back here from the endpoint Settings remembers. - const resolved = yield* resolve( - { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_LOGS_EXPORTER: "none", - }, - { persisted: { logs: "https://stored.example.com/v1/logs" } }, - ); - assert.strictEqual(resolved.logs.url, undefined); - assert.strictEqual(resolved.traces.url, "https://collector.example.com/v1/traces"); - }), - ); - - it.effect("keeps one T3 Code interval off the endpoints it did not name", () => - Effect.gen(function* () { - // The source that named an endpoint sets the cadence of the export it - // configured. Letting `T3CODE_OTLP_EXPORT_INTERVAL_MS` reach across - // would have it pace an export it knows nothing about, and would - // discard the per-signal number standing right next to that endpoint. - const resolved = yield* resolve( - { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_METRIC_EXPORT_INTERVAL: "30000", - }, - { namedExportIntervalMs: 2500 }, - ); - assert.strictEqual(resolved.metrics.exportIntervalMs, 30_000); - assert.strictEqual(resolved.traces.exportIntervalMs, 5_000); - assert.strictEqual(resolved.logs.exportIntervalMs, 1_000); - }), - ); - - it.effect("falls back to the interval the specification defines for each signal", () => - Effect.gen(function* () { - const resolved = yield* resolve({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - }); - assert.strictEqual(resolved.traces.exportIntervalMs, 5_000); - assert.strictEqual(resolved.metrics.exportIntervalMs, 60_000); - assert.strictEqual(resolved.logs.exportIntervalMs, 1_000); - }), - ); - - it.effect("cannot be made to claim it is the server process", () => - Effect.gen(function* () { - const resolved = yield* resolve({ - OTEL_SERVICE_VERSION: "1.2.3", - OTEL_RESOURCE_ATTRIBUTES: "deployment.environment=lab,service.runtime=t3-server", - }); - assert.strictEqual(resolved.resource.serviceName, "t3code-desktop"); - assert.strictEqual(resolved.resource.serviceVersion, "1.2.3"); - assert.strictEqual(resolved.resource.attributes["deployment.environment"], "lab"); - assert.strictEqual(resolved.resource.attributes["service.runtime"], "desktop"); - }), - ); - - it.effect("cannot be renamed by the environment", () => - Effect.gen(function* () { - const resolved = yield* resolve({ - OTEL_SERVICE_NAME: "some-other-app", - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - }); - assert.strictEqual(resolved.resource.serviceName, "t3code-desktop"); - assert.lengthOf(resolved.warnings, 1); - assert.include(resolved.warnings[0] ?? "", "OTEL_SERVICE_NAME was ignored"); - }), - ); - - it.effect("cannot be renamed through the resource attributes either", () => - Effect.gen(function* () { - const resolved = yield* resolve({ - OTEL_RESOURCE_ATTRIBUTES: "service.name=some-other-app,host.name=lab-01", - }); - assert.strictEqual(resolved.resource.serviceName, "t3code-desktop"); - assert.strictEqual(resolved.resource.attributes["service.name"], undefined); - assert.strictEqual(resolved.resource.attributes["host.name"], "lab-01"); - assert.include(resolved.warnings[0] ?? "", "service.name was ignored"); - }), - ); - - it.effect("names itself even when the environment says nothing", () => - Effect.gen(function* () { - const resolved = yield* resolve({}); - assert.strictEqual(resolved.resource.serviceName, "t3code-desktop"); - assert.strictEqual(resolved.resource.serviceVersion, undefined); - }), - ); -}); diff --git a/apps/desktop/src/app/DesktopOtlpExport.ts b/apps/desktop/src/app/DesktopOtlpExport.ts deleted file mode 100644 index 6dfb433f086c..000000000000 --- a/apps/desktop/src/app/DesktopOtlpExport.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * What the Electron main process exports, and where. - * - * The main process is its own OpenTelemetry producer: it owns app startup, - * window and menu work, backend supervision, and updates, none of which the - * server process can see. It reads the same sources as the server and in the - * same order, so a machine that points one of them at a collector points both. - * - * @module app/DesktopOtlpExport - */ -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; - -/** - * The interval T3 Code has always used for a `T3CODE_OTLP_*` or Settings - * endpoint. An `OTEL_*` endpoint brings the specification's own per-signal - * default instead. - */ -export const DEFAULT_DESKTOP_EXPORT_INTERVAL_MS = 10_000; - -/** - * The wire format a `T3CODE_OTLP_*` or Settings endpoint is sent when - * `T3CODE_OTLP_PROTOCOL` does not name one. - */ -const DEFAULT_DESKTOP_PROTOCOL: OtelEnvironment.OtlpProtocol = "http/json"; - -export interface DesktopOtlpSignal { - readonly url: string | undefined; - readonly exportIntervalMs: number; - readonly protocol: OtelEnvironment.OtlpProtocol; - readonly headers: Readonly> | undefined; - readonly maxBatchSize: number | undefined; - readonly temporality: OtelEnvironment.MetricsTemporality; -} - -export interface DesktopOtlpResource { - readonly serviceName: string; - readonly serviceVersion: string | undefined; - readonly attributes: Readonly>; -} - -export interface DesktopOtlpExport { - readonly traces: DesktopOtlpSignal; - readonly metrics: DesktopOtlpSignal; - readonly logs: DesktopOtlpSignal; - readonly resource: DesktopOtlpResource; - /** Everything worth saying out loud once, already phrased for a human. */ - readonly warnings: ReadonlyArray; -} - -/** One endpoint per signal, from a single source. */ -export interface DesktopOtlpEndpoints { - readonly traces: string | undefined; - readonly metrics: string | undefined; - readonly logs: string | undefined; -} - -export interface DesktopOtlpExportInput { - readonly otel: OtelEnvironment.OtelEnvironment; - /** `T3CODE_OTLP_*`, which outranks everything. */ - readonly named: DesktopOtlpEndpoints; - /** Settings, which answers under both sets of variables. */ - readonly persisted: DesktopOtlpEndpoints; - /** `T3CODE_OTLP_EXPORT_INTERVAL_MS`, which deliberately covers every signal. */ - readonly namedExportIntervalMs: number | undefined; - /** `T3CODE_OTLP_HEADERS`, which deliberately covers every signal. */ - readonly namedHeaders: Readonly> | undefined; - /** `T3CODE_OTLP_PROTOCOL`, which deliberately covers every signal. */ - readonly namedProtocol: OtelEnvironment.OtlpProtocol | undefined; - /** What this process calls itself. The environment cannot rename it. */ - readonly serviceName: string; - /** - * What this process is. Applied last so an ambient - * `OTEL_RESOURCE_ATTRIBUTES` cannot make the main process claim to be the - * server. - */ - readonly runtimeAttributes: Readonly>; -} - -const offSignal: DesktopOtlpSignal = { - url: undefined, - exportIntervalMs: DEFAULT_DESKTOP_EXPORT_INTERVAL_MS, - protocol: DEFAULT_DESKTOP_PROTOCOL, - headers: undefined, - maxBatchSize: undefined, - temporality: OtelEnvironment.DEFAULT_METRICS_TEMPORALITY, -}; - -/** - * Turns the source that won one signal into what the exporter needs. - * `OtelEnvironment.resolveSignalSource` has already decided which source that - * is, and hands back settings only when the `OTEL_*` variables are the ones - * that named the endpoint. - */ -const resolveSignal = ( - resolved: { readonly url: string | undefined; readonly signal: OtelEnvironment.OtlpSignal }, - input: DesktopOtlpExportInput, -): DesktopOtlpSignal => { - if (resolved.url === undefined) { - return offSignal; - } - return { - url: resolved.url, - ...OtelEnvironment.resolveSignalExport({ - settings: resolved.signal.settings, - t3Protocol: input.namedProtocol ?? DEFAULT_DESKTOP_PROTOCOL, - t3Headers: input.namedHeaders, - t3ExportIntervalMs: input.namedExportIntervalMs ?? DEFAULT_DESKTOP_EXPORT_INTERVAL_MS, - }), - }; -}; - -export const resolveDesktopOtlpExport = (input: DesktopOtlpExportInput): DesktopOtlpExport => { - const { otel } = input; - const resource: DesktopOtlpResource = { - serviceName: input.serviceName, - serviceVersion: otel.serviceVersion, - attributes: { ...otel.resourceAttributes, ...input.runtimeAttributes }, - }; - - if (otel.disabled) { - return { - traces: offSignal, - metrics: offSignal, - logs: offSignal, - resource, - warnings: [...otel.warnings], - }; - } - - const traces = OtelEnvironment.resolveSignalSource({ - t3Url: input.named.traces, - signal: otel.traces, - persistedUrl: input.persisted.traces, - }); - const metrics = OtelEnvironment.resolveSignalSource({ - t3Url: input.named.metrics, - signal: otel.metrics, - persistedUrl: input.persisted.metrics, - }); - const logs = OtelEnvironment.resolveSignalSource({ - t3Url: input.named.logs, - signal: otel.logs, - persistedUrl: input.persisted.logs, - }); - - // One variable can decline every signal, and saying so three times reads - // like three separate problems. - return { - traces: resolveSignal(traces, input), - metrics: resolveSignal(metrics, input), - logs: resolveSignal(logs, input), - resource, - warnings: [ - ...new Set([ - ...otel.warnings, - ...[traces.signal.declined, metrics.signal.declined, logs.signal.declined].filter( - (reason): reason is string => reason !== undefined, - ), - ]), - ], - }; -}; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 9ecada2b5af3..07644a8af47c 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -952,116 +952,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("resolveWsl forwards the standard OTEL variables into the distro", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - const previousWslEnv = process.env.WSLENV; - const previousEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; - const previousHeaders = process.env.OTEL_EXPORTER_OTLP_HEADERS; - const previousTemporality = process.env.OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE; - try { - // The bootstrap carries the resolved URLs but nothing else these - // variables say, and it is the lowest-priority source. Without the - // names crossing too, a Windows machine would reach the collector - // inside the distro unauthenticated, in the wrong wire format, and - // with an aggregation the receiver drops. - delete process.env.WSLENV; - process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://collector.example.com"; - process.env.OTEL_EXPORTER_OTLP_HEADERS = "authorization=Bearer%20ambient"; - process.env.OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "delta"; - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - - assert.equal(config.env.OTEL_EXPORTER_OTLP_ENDPOINT, "https://collector.example.com"); - const declared = (config.env.WSLENV ?? "").split(":"); - assert.include(declared, "OTEL_EXPORTER_OTLP_ENDPOINT"); - assert.include(declared, "OTEL_EXPORTER_OTLP_HEADERS"); - assert.include(declared, "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"); - // A bare entry crosses verbatim. A path flag would rewrite a URL. - assert.notInclude(config.env.WSLENV ?? "", "OTEL_EXPORTER_OTLP_ENDPOINT/"); - }).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" })), - ), - ), - ); - } finally { - restoreEnv("WSLENV", previousWslEnv); - restoreEnv("OTEL_EXPORTER_OTLP_ENDPOINT", previousEndpoint); - restoreEnv("OTEL_EXPORTER_OTLP_HEADERS", previousHeaders); - restoreEnv("OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", previousTemporality); - } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolveWsl forwards T3 Code's own endpoint under its own name", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - const previousWslEnv = process.env.WSLENV; - const previousTracesUrl = process.env.T3CODE_OTLP_TRACES_URL; - const previousEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; - try { - // The bootstrap carries this URL too, but it cannot say which variable - // put it there, and the bootstrap is the lowest-priority source. Only - // the name crossing keeps T3 Code's own variable outranking an ambient - // endpoint inside the distro the way it does everywhere else. - delete process.env.WSLENV; - process.env.T3CODE_OTLP_TRACES_URL = "http://localhost:4318/v1/traces"; - process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://collector.example.com"; - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - - assert.equal(config.env.T3CODE_OTLP_TRACES_URL, "http://localhost:4318/v1/traces"); - assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_OTLP_TRACES_URL"); - assert.notInclude(config.env.WSLENV ?? "", "T3CODE_OTLP_TRACES_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" })), - ), - ), - ); - } finally { - restoreEnv("WSLENV", previousWslEnv); - restoreEnv("T3CODE_OTLP_TRACES_URL", previousTracesUrl); - restoreEnv("OTEL_EXPORTER_OTLP_ENDPOINT", previousEndpoint); - } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("resolveWsl carries the kill switch into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 50fb49e43173..b3bec8830ec6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -92,29 +92,20 @@ const DESKTOP_BACKEND_ENV_NAMES = [ // Env vars that the WSL backend needs but Windows process.env won't forward // across the wsl.exe boundary without WSLENV. The dev-server URL travels as // the `--dev-url` CLI flag instead. -// -// Every name the server reads to decide what it exports and where. These cross -// without a WSLENV flag, so their values arrive verbatim; only a `/p`, `/l`, -// `/u`, or `/w` entry is path-translated, which is what makes URL-shaped names -// safe to forward. -// -// The endpoints also reach a WSL backend through the bootstrap envelope, but the -// bootstrap is the lowest-priority source and cannot say which variable put a -// URL in it. Forwarding the names themselves is what keeps precedence inside -// the distro the same as on every other platform: `T3CODE_OTLP_*_URL` has to -// arrive under its own name to outrank an ambient `OTEL_EXPORTER_OTLP_ENDPOINT`, -// and the `OTEL_*` knobs have to travel with their endpoint or a collector is -// reached unauthenticated and in the wrong wire format because only the URL -// made the trip. -const OBSERVABILITY_FORWARDED_ENV_NAMES = [ +const WSL_FORWARDED_ENV_NAMES = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. "T3CODE_OTEL_SDK_DISABLED", + "OTEL_SDK_DISABLED", + "T3CODE_OTLP_HEADERS", + "T3CODE_OTLP_PROTOCOL", + // Forwarded without a WSLENV flag, so the values arrive untranslated. The + // server prefers an OTEL endpoint over the bootstrap envelope, so the T3 URLs + // travel as variables to keep winning inside the distro as they do on Windows. "T3CODE_OTLP_TRACES_URL", "T3CODE_OTLP_METRICS_URL", "T3CODE_OTLP_LOGS_URL", - "T3CODE_OTLP_HEADERS", - "T3CODE_OTLP_PROTOCOL", - "T3CODE_OTLP_EXPORT_INTERVAL_MS", - "OTEL_SDK_DISABLED", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", @@ -127,24 +118,6 @@ const OBSERVABILITY_FORWARDED_ENV_NAMES = [ "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", - "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", - "OTEL_TRACES_EXPORTER", - "OTEL_METRICS_EXPORTER", - "OTEL_LOGS_EXPORTER", - "OTEL_BSP_SCHEDULE_DELAY", - "OTEL_BSP_MAX_EXPORT_BATCH_SIZE", - "OTEL_BLRP_SCHEDULE_DELAY", - "OTEL_BLRP_MAX_EXPORT_BATCH_SIZE", - "OTEL_METRIC_EXPORT_INTERVAL", - "OTEL_SERVICE_NAME", - "OTEL_SERVICE_VERSION", - "OTEL_RESOURCE_ATTRIBUTES", -] as const; - -const WSL_FORWARDED_ENV_NAMES = [ - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - ...OBSERVABILITY_FORWARDED_ENV_NAMES, ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 63744147c1d6..aecf82eeac67 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -15,6 +15,7 @@ import { } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -102,9 +103,9 @@ const makeCliTestServerConfig = (baseDir: string) => otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otelEnvironment: OtelEnvironment.none, mode: "web", port: 0, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 6da622ff01f8..ef7cdd578899 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -1,7 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; import { assert, expect, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -18,9 +17,9 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as ServerConfig from "../config.ts"; import { deriveServerPaths } from "../config.ts"; import { resolveServerConfig } from "./config.ts"; @@ -54,9 +53,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otelEnvironment: OtelEnvironment.none, devAllowedOrigins: [], } as const; @@ -596,239 +595,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - // Resolving a config reads the settings file and creates the trace - // directory, so a shared home would let one case see another's writes and - // would race when these run in parallel. - const resolveWithEnv = (env: Record) => { - const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-otel-config-")); - return resolveServerConfig( - { - mode: Option.some("web"), - port: Option.some(4888), - 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 })), NetService.layer), - ), - Effect.ensuring( - Effect.sync(() => { - NodeFS.rmSync(baseDir, { recursive: true, force: true }); - }), - ), - ); - }; - - it.effect("exports to the endpoint the rest of the machine already uses", () => - Effect.gen(function* () { - const resolved = yield* resolveWithEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_SERVICE_NAME: "t3", - }); - - 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"); - // The endpoint is the machine's to name. The service is not. - expect(ServerConfig.otlpResource(resolved).serviceName).toBe("t3code-server"); - }), - ); - - it.effect("cannot be renamed by the environment", () => - Effect.gen(function* () { - // A shell profile that names the app it was written for must not decide - // what T3 Code calls itself, or one dashboard quietly covers two apps. - const resolved = yield* resolveWithEnv({ - OTEL_SERVICE_NAME: "some-other-app", - OTEL_RESOURCE_ATTRIBUTES: "service.name=some-other-app", - }); - - expect(ServerConfig.otlpResource(resolved).serviceName).toBe("t3code-server"); - }), - ); - - it.effect("does not let an empty T3 Code name stand in for an answer", () => - Effect.gen(function* () { - // An empty variable is set without saying anything. Reading it as an - // answer would publish an endpoint nothing can reach and would suppress - // the ambient one that could have been used instead. - const resolved = yield* resolveWithEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_SERVICE_NAME: "t3", - T3CODE_OTLP_TRACES_URL: "", - T3CODE_OTLP_METRICS_URL: " ", - T3CODE_OTLP_LOGS_URL: "", - }); - - 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(ServerConfig.otlpResource(resolved).serviceName).toBe("t3code-server"); - }), - ); - - it.effect("keeps T3 Code's own names as the explicit answer", () => - Effect.gen(function* () { - const resolved = yield* resolveWithEnv({ - 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", - }); - - 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(ServerConfig.otlpResource(resolved).serviceName).toBe("t3code-server"); - }), - ); - - it.effect("leaves a T3 Code endpoint alone when the environment names another", () => - Effect.gen(function* () { - // An ambient endpoint that lost the URL must not keep configuring the - // export around it: its wire format, headers, and batching belong to the - // endpoint it named, not to this one. - const resolved = yield* resolveWithEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces", - }); - - expect(resolved.otelEnvironment.traces.settings).toBeUndefined(); - expect(resolved.otelEnvironment.metrics.settings?.url).toBe( - "https://collector.example.com/v1/metrics", - ); - expect(resolved.otlpTracesExport.exportIntervalMs).toBe(10_000); - }), - ); - - it.effect("keeps an ambient aggregation off a T3 Code metric endpoint", () => - Effect.gen(function* () { - const resolved = yield* resolveWithEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "delta", - T3CODE_OTLP_METRICS_URL: "http://localhost:4318/v1/metrics", - }); - - expect(resolved.otelEnvironment.metrics.settings).toBeUndefined(); - expect(resolved.otelEnvironment.traces.settings?.temporality).toBeUndefined(); - }), - ); - - it.effect("keeps one signal's schedule off the other one", () => - Effect.gen(function* () { - // Traces take the specification's five second batch delay from the - // ambient endpoint. Metrics went somewhere else and keep T3 Code's own - // interval rather than inheriting a number meant for spans. - const resolved = yield* resolveWithEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - T3CODE_OTLP_METRICS_URL: "http://localhost:4318/v1/metrics", - }); - - expect(resolved.otlpTracesExport.exportIntervalMs).toBe(5_000); - expect(resolved.otlpMetricsExport.exportIntervalMs).toBe(10_000); - }), - ); - - it.effect("keeps a T3 Code credential off an endpoint the standard variables named", () => - Effect.gen(function* () { - // The source that named the endpoint configures the whole signal. An - // `OTEL_*` endpoint that says nothing about headers is asking for none, - // not asking to borrow the token T3 Code's own variable carries. - const resolved = yield* resolveWithEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - T3CODE_OTLP_HEADERS: "authorization=Bearer%20t3-token", - T3CODE_OTLP_PROTOCOL: "http/json", - }); - - expect(resolved.otlpTracesExport.headers).toBeUndefined(); - expect(resolved.otlpTracesExport.protocol).toBe("http/protobuf"); - }), - ); - - it.effect("carries a T3 Code credential to the endpoint T3 Code named", () => - Effect.gen(function* () { - const resolved = yield* resolveWithEnv({ - T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces", - T3CODE_OTLP_HEADERS: "authorization=Bearer%20t3-token", - }); - - expect(resolved.otlpTracesExport.headers).toEqual({ - authorization: "Bearer t3-token", - }); - expect(resolved.otlpTracesExport.protocol).toBe("http/json"); - }), - ); - - 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.otlpTracesExport.exportIntervalMs).toBe(7_000); - expect(resolved.otlpLogsExport.exportIntervalMs).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 - // about a signal whose endpoint came from a T3 Code name. - const resolved = yield* resolveWithEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_PROTOCOL: "grpc", - T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces", - }); - - 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"); - }), - ); - - it.effect("exports nothing at all once the SDK is switched off", () => - Effect.gen(function* () { - const resolved = yield* resolveWithEnv({ - OTEL_SDK_DISABLED: "true", - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces", - }); - - expect(resolved.otlpTracesUrl).toBeUndefined(); - expect(resolved.otlpMetricsUrl).toBeUndefined(); - expect(resolved.otlpLogsUrl).toBeUndefined(); - }), - ); - - it.effect("keeps exporting when T3 Code's own name says to, whatever the standard one says", () => - Effect.gen(function* () { - const resolved = yield* resolveWithEnv({ - T3CODE_OTEL_SDK_DISABLED: "false", - OTEL_SDK_DISABLED: "true", - T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces", - }); - - expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); - }), - ); - it.effect("falls back to persisted observability settings when env vars are absent", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1004,236 +770,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("does not let a blank bootstrap endpoint hide the stored one", () => - Effect.gen(function* () { - // The desktop sends the envelope whether or not it resolved an endpoint, - // so an empty string means "I found nothing", not "export nowhere". It - // must not stand in front of the Settings endpoint underneath it. - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-blank-" }); - const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); - yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); - yield* fs.writeFileString( - derivedPaths.settingsPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - `${JSON.stringify({ - observability: { otlpTracesUrl: "http://stored.example.com/v1/traces" }, - })}\n`, - ); - const fd = yield* openBootstrapFd( - makeDesktopBootstrap({ t3Home: baseDir, otlpTracesUrl: "" }), - ); - - const resolved = yield* resolveServerConfig( - { - mode: Option.none(), - port: Option.none(), - host: Option.none(), - baseDir: Option.none(), - 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_BOOTSTRAP_FD: String(fd) } }), - ), - NetService.layer, - ), - ), - ); - - expect(resolved.otlpTracesUrl).toBe("http://stored.example.com/v1/traces"); - }), - ); - - it.effect("reads an exported endpoint before a stored one", () => - Effect.gen(function* () { - // An exported variable is what the operator asked for now; Settings is - // what somebody asked for once. The standard names sit directly under - // T3 Code's own, not under the file, which is the order every setting - // here follows. - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-order-" }); - const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); - yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); - yield* fs.writeFileString( - derivedPaths.settingsPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - `${JSON.stringify({ - observability: { - otlpTracesUrl: "http://stored.example.com/v1/traces", - otlpMetricsUrl: "http://stored.example.com/v1/metrics", - otlpLogsUrl: "http://stored.example.com/v1/logs", - }, - })}\n`, - ); - - const resolved = yield* resolveServerConfig( - { - mode: Option.some("desktop"), - port: Option.some(4888), - 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: { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - T3CODE_OTLP_LOGS_URL: "http://localhost:4318/v1/logs", - }, - }), - ), - NetService.layer, - ), - ), - ); - - expect(resolved.otlpTracesUrl).toBe("https://collector.example.com/v1/traces"); - expect(resolved.otlpMetricsUrl).toBe("https://collector.example.com/v1/metrics"); - // T3 Code's own name still outranks both, and taking the signal with it - // leaves the ambient wire format on the endpoint that asked for it. - expect(resolved.otlpLogsUrl).toBe("http://localhost:4318/v1/logs"); - expect(resolved.otelEnvironment.traces.settings?.protocol).toBe("http/protobuf"); - expect(resolved.otelEnvironment.logs.settings).toBeUndefined(); - }), - ); - - it.effect("keeps a stored endpoint from re-enabling a signal turned off by name", () => - Effect.gen(function* () { - // Turning one signal off is the most common reason to touch an exporter - // list, and a Settings endpoint underneath used to quietly keep sending - // it, which is the failure the operator was trying to prevent. - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-off-" }); - const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); - yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); - yield* fs.writeFileString( - derivedPaths.settingsPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - `${JSON.stringify({ - observability: { - otlpTracesUrl: "http://stored.example.com/v1/traces", - otlpLogsUrl: "http://stored.example.com/v1/logs", - }, - })}\n`, - ); - - const resolved = yield* resolveServerConfig( - { - mode: Option.none(), - port: Option.none(), - 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: { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_LOGS_EXPORTER: "none", - }, - }), - ), - NetService.layer, - ), - ), - ); - - expect(resolved.otlpLogsUrl).toBeUndefined(); - expect(resolved.otlpTracesUrl).toBe("https://collector.example.com/v1/traces"); - }), - ); - - it.effect("falls back to a stored endpoint for the signals nothing exported", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-order-signal-" }); - const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); - yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); - yield* fs.writeFileString( - derivedPaths.settingsPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - `${JSON.stringify({ - observability: { otlpMetricsUrl: "http://stored.example.com/v1/metrics" }, - })}\n`, - ); - - const resolved = yield* resolveServerConfig( - { - mode: Option.some("desktop"), - port: Option.some(4888), - 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: { - OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://collector.example.com/v1/traces", - }, - }), - ), - NetService.layer, - ), - ), - ); - - // The three signals are answered separately, so a variable that named - // one endpoint does not decide where the others go. - expect(resolved.otlpTracesUrl).toBe("https://collector.example.com/v1/traces"); - expect(resolved.otlpMetricsUrl).toBe("http://stored.example.com/v1/metrics"); - expect(resolved.otlpLogsUrl).toBeUndefined(); - expect(resolved.otelEnvironment.metrics.settings).toBeUndefined(); - }), - ); - it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () => Effect.gen(function* () { const { join } = yield* Path.Path; @@ -1537,7 +1073,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { // Metrics named no T3CODE_OTLP_METRICS_URL, so the OTEL endpoint wins // over the bootstrap envelope and brings the OTEL headers and protocol. expect(resolved.otlpMetricsUrl).toBe("http://otel-metrics:4318/custom"); - expect(resolved.otlpMetricsExport).toMatchObject({ + expect(resolved.otlpMetricsExport).toEqual({ + ...DEFAULT_SIGNAL_EXPORT, protocol: "http/protobuf", headers: { "x-key": "otel" }, }); @@ -1549,58 +1086,58 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("keeps an OTEL endpoint's claim on a signal when only its headers do not read", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ - prefix: "t3-cli-config-otel-off-", - }); - const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); - yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); - yield* fs.writeFileString( - derivedPaths.settingsPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, - ); + it.effect( + "exports nothing for a signal an OTEL endpoint claimed with a protocol or headers that do not read", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-cli-config-otel-off-", + }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, + ); - const fd = yield* openBootstrapFd( - makeDesktopBootstrap({ otlpMetricsUrl: "http://bootstrap:4318/v1/metrics" }), - ); + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ otlpMetricsUrl: "http://bootstrap:4318/v1/metrics" }), + ); - const resolved = yield* resolveServerConfig( - { - ...minimalWebFlags(baseDir), - mode: Option.some("desktop"), - port: Option.some(4888), - bootstrapFd: Option.some(fd), - }, - Option.none(), - ).pipe( - Effect.provide( - Layer.mergeAll( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces", - OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel:4318", - OTEL_EXPORTER_OTLP_HEADERS: "x-key=%zz", - }, - }), + const resolved = yield* resolveServerConfig( + { + ...minimalWebFlags(baseDir), + mode: Option.some("desktop"), + port: Option.some(4888), + bootstrapFd: Option.some(fd), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel:4318", + OTEL_EXPORTER_OTLP_HEADERS: "x-key=%zz", + }, + }), + ), + NetService.layer, ), - NetService.layer, ), - ), - ); + ); - // T3CODE_OTLP_TRACES_URL still wins outright. - expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces"); - // A bad header list costs only the headers: the OTEL endpoint still - // claims metrics and logs over the bootstrap envelope and Settings. - expect(resolved.otlpMetricsUrl).toBe("http://otel:4318/v1/metrics"); - expect(resolved.otlpMetricsExport.headers).toBeUndefined(); - expect(resolved.otlpLogsUrl).toBe("http://otel:4318/v1/logs"); - expect(resolved.otlpLogsExport.headers).toBeUndefined(); - }), + // T3CODE_OTLP_TRACES_URL still wins outright. + expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces"); + // The OTEL endpoint claimed metrics and logs, so neither the bootstrap + // envelope nor Settings receives them with T3 Code's headers. + expect(resolved.otlpMetricsUrl).toBeUndefined(); + expect(resolved.otlpLogsUrl).toBeUndefined(); + }), ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 95eb24d76b9f..d724730c953f 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,5 +1,9 @@ import * as NetService from "@t3tools/shared/Net"; -import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability"; +import { + OtlpHeadersFromString, + OtlpProtocol, + type SignalExport, +} from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; @@ -102,8 +106,7 @@ const EnvServerConfig = Config.all({ Config.map(Option.getOrUndefined), ), otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( - Config.option, - Config.map(Option.getOrUndefined), + Config.withDefault(10_000), ), otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe( Config.option, @@ -252,7 +255,6 @@ export const resolveServerConfig = ( const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const env = yield* EnvServerConfig; - const otel = yield* OtelEnvironment.load; const normalizedFlags = { mode: flags.mode ?? Option.none(), port: flags.port ?? Option.none(), @@ -384,58 +386,36 @@ export const resolveServerConfig = ( ); const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); - // A blank bootstrap value is not an endpoint, so it must not stand in - // front of the Settings endpoint underneath it. `??` alone keeps the empty - // string and would discard both. - const persistedUrl = ( - bootstrapUrl: string | undefined, - settingsUrl: string | undefined, - ): string | undefined => - OtelEnvironment.blankAsUnset(bootstrapUrl) ?? OtelEnvironment.blankAsUnset(settingsUrl); - - const traces = OtelEnvironment.resolveSignalSource({ - t3Url: env.otlpTracesUrl, - signal: otel.traces, - persistedUrl: persistedUrl( - bootstrap?.otlpTracesUrl, - persistedObservabilitySettings.otlpTracesUrl, - ), - }); - const metrics = OtelEnvironment.resolveSignalSource({ - t3Url: env.otlpMetricsUrl, - signal: otel.metrics, - persistedUrl: persistedUrl( - bootstrap?.otlpMetricsUrl, - persistedObservabilitySettings.otlpMetricsUrl, - ), - }); - const logs = OtelEnvironment.resolveSignalSource({ - t3Url: env.otlpLogsUrl, - signal: otel.logs, - persistedUrl: persistedUrl( - bootstrap?.otlpLogsUrl, - persistedObservabilitySettings.otlpLogsUrl, - ), - }); - const otelEnvironment = { - ...otel, - traces: traces.signal, - metrics: metrics.signal, - logs: logs.signal, - } satisfies OtelEnvironment.OtelEnvironment; + const otel = yield* OtelEnvironment.load; - // T3 Code has one interval, one header set, and one wire format, and they - // deliberately cover every signal. They apply to the signals the standard - // variables did not claim; the standard variables bring their own - // per-signal defaults for the ones they did. - const signalExport = (settings: OtelEnvironment.OtlpSignalSettings | undefined) => - OtelEnvironment.resolveSignalExport({ - settings, - t3Protocol: env.otlpProtocol, - t3Headers: env.otlpHeaders, - t3ExportIntervalMs: - env.otlpExportIntervalMs ?? OtelEnvironment.DEFAULT_SIGNAL_EXPORT.exportIntervalMs, - }); + // T3 Code's own OTLP variables name no signal, so the one answer they give + // is the answer for all three. + const signalExport: SignalExport = { + protocol: env.otlpProtocol, + headers: env.otlpHeaders, + exportIntervalMs: env.otlpExportIntervalMs, + }; + const traces = OtelEnvironment.resolveSignalEndpoint( + otel, + "traces", + { url: env.otlpTracesUrl, export: signalExport }, + bootstrap?.otlpTracesUrl, + persistedObservabilitySettings.otlpTracesUrl, + ); + const metrics = OtelEnvironment.resolveSignalEndpoint( + otel, + "metrics", + { url: env.otlpMetricsUrl, export: signalExport }, + bootstrap?.otlpMetricsUrl, + persistedObservabilitySettings.otlpMetricsUrl, + ); + const logs = OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + { url: env.otlpLogsUrl, export: signalExport }, + bootstrap?.otlpLogsUrl, + persistedObservabilitySettings.otlpLogsUrl, + ); const config: ServerConfig.ServerConfig["Service"] = { logLevel, @@ -444,13 +424,13 @@ export const resolveServerConfig = ( traceBatchWindowMs: env.traceBatchWindowMs, traceMaxBytes: env.traceMaxBytes, traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: otelEnvironment.disabled ? undefined : traces.url, - otlpMetricsUrl: otelEnvironment.disabled ? undefined : metrics.url, - otlpLogsUrl: otelEnvironment.disabled ? undefined : logs.url, - otlpTracesExport: signalExport(otelEnvironment.traces.settings), - otlpMetricsExport: signalExport(otelEnvironment.metrics.settings), - otlpLogsExport: signalExport(otelEnvironment.logs.settings), - otelEnvironment, + otlpTracesUrl: traces?.url, + otlpMetricsUrl: metrics?.url, + otlpLogsUrl: logs?.url, + otlpTracesExport: traces?.export ?? signalExport, + otlpMetricsExport: metrics?.export ?? signalExport, + otlpLogsExport: logs?.export ?? signalExport, + otelEnvironment: otel, mode, port, cwd, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index a743672572da..04ce0332c14c 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -15,6 +15,7 @@ import { PortSchema, } from "@t3tools/contracts"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { buildTailscaleHttpsBaseUrl, @@ -322,9 +323,9 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otelEnvironment: OtelEnvironment.none, mode: "web", port: state.port, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 53279bfe5641..74ffde8efef0 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,7 +6,6 @@ * * @module ServerConfig */ -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; @@ -18,6 +17,8 @@ import type * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; +import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; export const DEFAULT_PORT = 3773; @@ -74,20 +75,13 @@ export class ServerConfig extends Context.Service< readonly otlpMetricsUrl: string | undefined; readonly otlpLogsUrl: string | undefined; /** - * How each signal is exported, already resolved to the source that named - * that signal's endpoint. This is the only place the wire format, headers, - * batching, and aggregation are read from, so a setting cannot be paired - * by hand with an endpoint that came from somewhere else. - */ - readonly otlpTracesExport: OtelEnvironment.SignalExport; - readonly otlpMetricsExport: OtelEnvironment.SignalExport; - readonly otlpLogsExport: OtelEnvironment.SignalExport; - /** - * What the standard `OTEL_*` variables asked for. The endpoints above are - * already resolved from it; this carries the rest, which T3 Code has no - * names of its own for per signal: headers, wire format, resource - * attributes, and the batching knobs. + * How each signal is exported. Read instead of a process-wide setting so + * the wire format, credential, and schedule travel with the endpoint they + * were configured beside. */ + readonly otlpTracesExport: SignalExport; + readonly otlpMetricsExport: SignalExport; + readonly otlpLogsExport: SignalExport; readonly otelEnvironment: OtelEnvironment.OtelEnvironment; readonly mode: RuntimeMode; readonly port: number; @@ -119,26 +113,21 @@ export class ServerConfig extends Context.Service< 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. + * 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: "t3code-server", - ...(config.otelEnvironment.serviceVersion === undefined - ? {} - : { serviceVersion: config.otelEnvironment.serviceVersion }), attributes: { - ...config.otelEnvironment.resourceAttributes, "service.namespace": "t3code", "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* ( baseDir: ServerConfig["Service"]["baseDir"], devUrl: ServerConfig["Service"]["devUrl"], @@ -230,9 +219,9 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otelEnvironment: OtelEnvironment.none, cwd, baseDir, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index ad09b890bfc1..6eb95c84b477 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -9,6 +9,7 @@ import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -55,9 +56,9 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otelEnvironment: OtelEnvironment.none, cwd: process.cwd(), baseDir, diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index b9dac2a1f644..77ebe8410a91 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -1,10 +1,10 @@ import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { makeLocalFileTracer, makeTraceSink, otlpSerializationLayer, } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as References from "effect/References"; @@ -21,23 +21,14 @@ import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - const attribution = yield* ResourceAttribution.ResourceAttribution; - const otel = config.otelEnvironment; - // 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, otel.logs.declined].filter( - (reason) => reason !== undefined, - ), - ); - - // Each signal builds its own serializer, so the wire format travels with - // the endpoint that asked for it rather than with this process. - const serializationFor = (signal: OtelEnvironment.SignalExport) => - otlpSerializationLayer(signal.protocol); - - const otlpResource = ServerConfig.otlpResource(config); + const traces = config.otlpTracesExport; + const metrics = config.otlpMetricsExport; + // The trace serializer stays in the returned context because the browser + // trace forwarder exports on the same signal. + const serializationLayer = otlpSerializationLayer(traces.protocol); + const resource = ServerConfig.otlpResource(config); + const attribution = yield* ResourceAttribution.ResourceAttribution; const traceReferencesLayer = Layer.mergeAll( Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), @@ -66,12 +57,9 @@ export const ObservabilityLive = Layer.unwrap( ? undefined : yield* OtlpTracer.make({ url: config.otlpTracesUrl, - exportInterval: `${config.otlpTracesExport.exportIntervalMs} millis`, - resource: otlpResource, - headers: config.otlpTracesExport.headers, - ...(config.otlpTracesExport.maxBatchSize === undefined - ? {} - : { maxBatchSize: config.otlpTracesExport.maxBatchSize }), + exportInterval: `${traces.exportIntervalMs} millis`, + headers: traces.headers, + resource, }); const tracer = yield* makeLocalFileTracer({ @@ -88,28 +76,21 @@ export const ObservabilityLive = Layer.unwrap( BrowserTraceCollector.layer(sink), ); }), - ).pipe( - Layer.provide(OtlpExporter.layerFlusher), - // The trace serializer is also the one this layer hands out, because the - // proxy in http.ts re-encodes browser spans and has to reach the trace - // collector in the format that collector was configured for. - Layer.provideMerge(serializationFor(config.otlpTracesExport)), - ); + ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(serializationLayer)); const metricsLayer = config.otlpMetricsUrl === undefined ? Layer.empty : OtlpMetrics.layer({ url: config.otlpMetricsUrl, - exportInterval: `${config.otlpMetricsExport.exportIntervalMs} millis`, - resource: otlpResource, - headers: config.otlpMetricsExport.headers, - temporality: config.otlpMetricsExport.temporality, - }).pipe(Layer.provide(serializationFor(config.otlpMetricsExport))); + exportInterval: `${metrics.exportIntervalMs} millis`, + headers: metrics.headers, + resource, + }).pipe(Layer.provide(otlpSerializationLayer(metrics.protocol))); // Logged once the server's loggers are installed, so the warnings use them. const otelWarningsLayer = Layer.effectDiscard( - Effect.forEach([...otel.warnings, ...declined], (warning) => Effect.logWarning(warning)), + Effect.forEach(config.otelEnvironment.warnings, (warning) => Effect.logWarning(warning)), ); return otelWarningsLayer.pipe( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 62cb9e9b4411..aa01591e9302 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -224,9 +224,9 @@ import { type TransferBudgetRun, transferBudgetViolations, } from "../integration/TransferBudgetReport.integration.ts"; -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -import { otlpSerializationLayer } from "@t3tools/shared/observability"; +import { DEFAULT_SIGNAL_EXPORT, otlpSerializationLayer } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); @@ -589,9 +589,9 @@ const buildAppUnderTest = (options?: { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otelEnvironment: OtelEnvironment.none, mode: "desktop", port: 0, @@ -5595,10 +5595,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ config: { otlpTracesUrl: collector.url, - otlpTracesExport: { - ...OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - protocol: "http/protobuf", - }, + otlpTracesExport: { ...DEFAULT_SIGNAL_EXPORT, protocol: "http/protobuf" }, }, layers: { browserTraceCollector: { diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 2be9915fd05c..43843b249eea 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -9,6 +9,7 @@ import * as Tracer from "effect/Tracer"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as ServerConfig from "./config.ts"; @@ -54,9 +55,9 @@ const configLayer = (overrides: Partial) = otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, - otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otelEnvironment: OtelEnvironment.none, cwd: baseDir, baseDir, @@ -187,17 +188,12 @@ describe("ServerLoggerLive", () => { it.effect("sends the headers and wire format the log signal asked for", () => Effect.gen(function* () { - // Which source won the log signal is settled before this point, so the - // logger reads the resolved export rather than pairing the URL with a - // header set that may belong to a different collector. const requests = yield* logThrough({ otlpLogsUrl: "https://collector.example.com/v1/logs", otlpLogsExport: { - ...OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + ...DEFAULT_SIGNAL_EXPORT, protocol: "http/protobuf", headers: { "x-scope": "logs" }, - exportIntervalMs: 1_000, - maxBatchSize: 512, }, }); diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index 3bbf86d5202e..6d19907c20ca 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -19,9 +19,8 @@ export const ServerLoggerLive = Effect.gen(function* () { : OtlpLogger.make({ url: config.otlpLogsUrl, exportInterval: `${logs.exportIntervalMs} millis`, + headers: logs.headers, resource: otlpResource(config), - ...(logs.headers === undefined ? {} : { headers: logs.headers }), - ...(logs.maxBatchSize === undefined ? {} : { maxBatchSize: logs.maxBatchSize }), }); // `Logger.layer` writes the whole logger set rather than adding to it, so diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 8e3abb684888..3f9d233ed278 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -160,25 +160,10 @@ 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({ @@ -187,48 +172,18 @@ 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 7d115653d710..d5c6359c1c9f 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -206,40 +206,23 @@ export function backgroundActivitySharedPolicySettings( }; } -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) { +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)) { return null; } - 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)) { + const tracesBase = input.tracesUrl.slice(0, -tracesSuffix.length); + const metricsBase = input.metricsUrl.slice(0, -metricsSuffix.length); + if (tracesBase !== metricsBase) { return null; } - 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]}`; + return `${tracesBase}/{traces,metrics}`; } export function formatDiagnosticsDescription(input: { @@ -248,26 +231,27 @@ 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 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 }] : [], - ); + 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}.`; + } - if (exports.length === 0) { - return `${mode}.`; + if (metricsUrl) { + return `${mode}. Exporting OTEL metrics to ${metricsUrl}.`; } - const collapsedUrl = collapseOtelSignalsUrl(exports); - return collapsedUrl - ? `${mode}. Exporting OTEL to ${collapsedUrl}.` - : `${mode}. Exporting OTEL ${formatOtelSignalList(exports)}.`; + return `${mode}.`; } export function buildProviderInstanceUpdatePatch(input: { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f09aae053cbb..f0a9113981b1 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -147,7 +147,6 @@ import { backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, durationToSeconds, - formatDiagnosticsDescription, getChangedBrowserSettingLabels, getChangedTypographySettingLabels, normalizeIntervalSeconds, @@ -2135,7 +2134,6 @@ export function GeneralSettingsPanel() { connectedEnvironments.every( (target) => target.serverConfig?.environment.capabilities.threadRestartContinuation === true, ); - const observability = environment?.serverConfig?.observability; const textGenerationProviders = serverProviders.filter( (provider) => provider.supportsTextGeneration !== false, @@ -3204,17 +3202,7 @@ export function GeneralSettingsPanel() { {...searchableSetting("diagnostics")} description={ isEnvironmentScope - ? `Inspect processes, resource use, and logs on this environment. ${formatDiagnosticsDescription( - { - localTracingEnabled: observability?.localTracingEnabled ?? false, - otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, - otlpTracesUrl: observability?.otlpTracesUrl, - otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false, - otlpMetricsUrl: observability?.otlpMetricsUrl, - otlpLogsEnabled: observability?.otlpLogsEnabled ?? false, - otlpLogsUrl: observability?.otlpLogsUrl, - }, - )}` + ? "Inspect processes, resource use, and logs on this environment." : "Inspect processes, resource use, and logs on one environment at a time." } control={ diff --git a/docs/fork/0018-the-standard-otel-variables-are-honored.md b/docs/fork/0018-the-standard-otel-variables-are-honored.md deleted file mode 100644 index ae48d3c307de..000000000000 --- a/docs/fork/0018-the-standard-otel-variables-are-honored.md +++ /dev/null @@ -1,91 +0,0 @@ -# 0018: The standard OTEL variables are honored - -- PR: [TrogonStack/t3code#31](https://github.com/TrogonStack/t3code/pull/31) -- Status: active - -## What you can do now - -- Point T3 Code at your collector the same way you point everything else at - it. A machine that already exports `OTEL_EXPORTER_OTLP_ENDPOINT` gets T3 Code - traces, metrics, and log records with no extra configuration, at the - per-signal paths the specification defines. -- Send the credentials your collector requires. `OTEL_EXPORTER_OTLP_HEADERS` - reaches the exporter, including the proxy that forwards browser traces, so an - authenticated endpoint stops rejecting the whole stream. -- Tell your instances apart. `OTEL_SERVICE_VERSION` and - `OTEL_RESOURCE_ATTRIBUTES` are attached to every span, metric, and log - record, so T3 Code sits in the same dashboards as everything else. Service - names themselves are static, and `OTEL_SERVICE_NAME` or a `service.name` in - `OTEL_RESOURCE_ATTRIBUTES` is refused with a startup warning instead of being - dropped in silence. -- Configure each signal on its own, logs included. A signal with its own - address, wire format, or credentials is honored without disturbing the other - two, `OTEL_LOGS_EXPORTER=none` stops log export while leaving spans and - metrics alone, and `OTEL_BLRP_SCHEDULE_DELAY` and - `OTEL_BLRP_MAX_EXPORT_BATCH_SIZE` are the batching knobs the specification - defines for logs, so a delay meant for spans does not decide how promptly a - log record arrives. -- Turn export off from the environment. `OTEL_SDK_DISABLED=true` stops every - export, including one configured in Settings, which is the one switch a - shared machine needs. `T3CODE_OTEL_SDK_DISABLED` is the same setting asked of - T3 Code's own name first, so `false` there keeps T3 Code exporting on a - machine whose profile disables every other SDK. -- Keep whatever you have. The `T3CODE_OTLP_*` names still win, and a setup that - never mentioned OpenTelemetry keeps the wire format it always used. The - standard names are read directly under T3 Code's own and above the desktop - bootstrap envelope and Settings, because an exported variable is what the - operator asked for now and a stored one is what somebody asked for once. -- Find out when a variable did not take. A misspelled protocol, a temporality - this exporter cannot produce, a batch size that is not a number, or a header - list that is not valid percent encoding is named in the startup log and then - ignored, instead of silently changing nothing or quietly turning export off. - One bad value costs you that value and nothing else. - -## Why - -T3 Code has had a real OTLP exporter for a while, and it was unreachable for -almost everyone who wanted it. You had to learn a second set of names for -settings you had already configured once, and the standard names for headers, -resource attributes, and the wire format reached nothing, so an authenticated -collector or a protobuf-only one simply could not be used. T3 Code has its own -header and protocol names now, but the standard ones still arrive through this -divergence. - -The cost of that shows up as silence rather than as an error. Someone with a -collector in their shell profile reasonably assumes the app found it, sees a -tidy local trace file, and never learns that nothing left the machine. Reading -the variables everyone else reads turns a feature that existed on paper into one -people can actually reach. - -Auto-enabling from an ambient endpoint is the deliberate part. Every other -OpenTelemetry SDK behaves this way, and a telemetry variable that some processes -honor and others quietly ignore is worse than either answer, so -`OTEL_SDK_DISABLED` is the way out rather than a requirement to opt in. - -Turning export off is one setting with two names, not two switches, and it is -read in the same order as everything else here: ours, then the standard one. -The ordering is the whole point. Inheriting `OTEL_SDK_DISABLED` from a shell -profile is common, and without a name of our own the only way to get T3 Code's -telemetry back would be to unset a variable the rest of the machine depends -on. - -## Upstream considerations - -Nothing here is fork-specific and it belongs upstream. Upstream has taken the -kill switch, the standard endpoint, header, and protocol variables, resource -attributes, and static service names. Its reader is a subset of this one, so the -sync keeps this reader and the fork carries the rest of this page: exporter -selection, the batching and temporality knobs, `OTEL_SERVICE_VERSION`, the -refusal warning for a service name, and a bad value costing that value rather -than the whole signal. The riskiest part for -them is the same part that makes it useful: an ambient endpoint starts an export -that includes thread ids, turn ids, and workspace paths, and upstream may prefer -an explicit opt-in for a product with this many users. - -The rebase burden is small. The reading lives in one module with no dependencies -on the rest of the server, and the wiring is one call per signal inside existing -precedence chains. A sync that rewrites those chains must keep the standard -names directly under the `T3CODE_OTLP_*` ones and above the desktop bootstrap -envelope and Settings, and must keep a signal those names switched off from -falling through to the stored endpoint underneath it. `resolveSignalSource` is -where that order lives, so both processes move together. diff --git a/docs/fork/0022-the-desktop-app-reports-its-own-work.md b/docs/fork/0022-the-desktop-app-reports-its-own-work.md deleted file mode 100644 index a2721da663ea..000000000000 --- a/docs/fork/0022-the-desktop-app-reports-its-own-work.md +++ /dev/null @@ -1,63 +0,0 @@ -# 0022: The desktop app reports its own work - -- PR: [TrogonStack/t3code#35](https://github.com/TrogonStack/t3code/pull/35) -- Status: active - -## What you can do now - -- See what the desktop app itself is doing. App startup, window and menu work, - backend supervision, and updates now reach your collector as traces and logs - under the service name `t3code-desktop`, alongside the server work they cause. -- Configure it the way you configure everything else. The Electron main process - reads the same `OTEL_*` endpoint variables as the server, in the same order, - so a machine that points one of them at a collector points both. It had a - trace exporter before, and only its own `T3CODE_OTLP_TRACES_URL` could reach - it, which almost nobody sets. -- Get logs from it, not only traces. A crash loop before the server is even up - used to leave nothing behind but a local file on the machine it happened on. - Metrics stay off while the main process records none, so a configured metrics - endpoint hears from the server and nobody else rather than receiving an empty - payload every interval. -- Turn it off the same way. `OTEL_SDK_DISABLED=true` stops both processes, and - so does `T3CODE_OTEL_SDK_DISABLED=true`, which is read first. -- Tell the two apart without trusting the environment. `service.runtime` on - the main process is always `desktop`, so an ambient - `OTEL_RESOURCE_ATTRIBUTES=service.runtime=t3-server` cannot make it file its - work under the server's name. - -## Why - -The desktop app is two processes, and only one of them was reachable. A user who -set up a collector got the server and quietly got nothing from the process that -starts it, owns its windows, and restarts it when it dies. The most useful -telemetry the desktop app could send is about the minutes before the server -exists, and those were the minutes nothing was recorded. - -The trace exporter that was already there is the sharper part of the story. It -was real, it worked, and it read one variable nobody sets, so it read as a -feature that had been tried and found not to help. It had not been tried. - -Reading the same variables in both processes is the whole point. A telemetry -variable that half an app honors is worse than one it ignores entirely, because -the half that arrives looks like the whole. - -## Upstream considerations - -This belongs upstream and depends on 0018 being there first: it is the same -environment reading applied to the other process, which is why the reading moved -into a shared package instead of being copied. Upstream taking 0018 gets this -almost for free. - -Upstream has since shipped its own desktop exporter and the kill switch for it, -so what remains here is the rest of the environment reading from 0018, applied to -the main process. Upstream's version settled two questions this one had -answered differently, and both of its answers were adopted: metrics stay off -until a desktop metric exists, and the OTLP log exporter replaces -`Logger.tracerLogger` instead of joining it, which is what the fork's own -server already did and which stops every in-span message being exported twice. - -The rebase burden is a single assembly point. The main process gets one logger -set for its lifetime, so the OTLP log exporter has to be built in the same call -that builds the console logger rather than merged in beside it. A sync that -rewrites that assembly and splits them apart will silently drop either the -console output or the export. diff --git a/docs/fork/README.md b/docs/fork/README.md index a69459e341ed..df2d9d4f76c3 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -49,12 +49,8 @@ Each entry uses these sections: active, [#27](https://github.com/TrogonStack/t3code/pull/27) - **0017** [A revoked Claude token reads as revoked](./0017-a-revoked-claude-token-reads-as-revoked.md) active, [#28](https://github.com/TrogonStack/t3code/pull/28) -- **0018** [The standard OTEL variables are honored](./0018-the-standard-otel-variables-are-honored.md) - 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) -- **0022** [The desktop app reports its own work](./0022-the-desktop-app-reports-its-own-work.md) - active, [#35](https://github.com/TrogonStack/t3code/pull/35) - **0024** [A refused merge says why, and an administrator can merge anyway](./0024-a-refused-merge-says-why.md) active, [#38](https://github.com/TrogonStack/t3code/pull/38) - **0025** [A test run leaves no processes behind](./0025-a-test-run-leaves-no-processes-behind.md) diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 7993443b1bcf..86a65702c270 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -16,7 +16,7 @@ stdout/stderr at `~/.t3/ssh-launch//server.log`. ### Logs -Every log the server writes goes to stdout for humans: +Logs are human-facing: - destination: stdout - format: `Logger.consolePretty()` @@ -24,11 +24,7 @@ Every log the server writes goes to stdout for humans: - SSH-managed launch persistence: `~/.t3/ssh-launch//server.log` - remote export: OTLP only, when configured -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. +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 @@ -85,7 +81,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/Loki +- full local observability: stdout + local trace file + OTLP export to Grafana/Tempo/Prometheus The local trace file is always on. OTLP export is opt-in. @@ -191,226 +187,6 @@ Do not rely on launching from Finder, Spotlight, the dock, or the Start menu aft The backend reads observability config at process start. If you change OTLP env vars, stop the app completely and start it again. -### Option 3: The Standard `OTEL_*` Variables - -If your machine already exports the OpenTelemetry environment variables for everything else running on -it, T3 Code joins in without being told twice. Nothing above is required: - -```bash -export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 -export OTEL_RESOURCE_ATTRIBUTES=deployment.environment=lab -``` - -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. If the reverse is -your problem, a profile that disables every other SDK on the machine, `T3CODE_OTEL_SDK_DISABLED=false` -keeps T3 Code exporting. - -#### Which Processes Export - -The desktop app is two processes, and each is its own OpenTelemetry producer: - -- **The server**, under service name `t3code-server`. -- **The Electron main process**, under service name `t3code-desktop`. It owns app startup, window and - menu work, backend supervision, and updates, none of which the server can see. It reads the same - sources in the same order as the server, so a machine that points one of them at a collector - points both. - -The web client reports as `t3code-web`, so the three service names are `t3code-server`, -`t3code-desktop`, and `t3code-web`, all in `service.namespace` `t3code`. - -**Service names are static and the environment cannot change them.** `OTEL_SERVICE_NAME` and a -`service.name` inside `OTEL_RESOURCE_ATTRIBUTES` are both refused, with a warning naming the one you -set. This is a deliberate departure from what most OpenTelemetry SDKs do, and the reason is that a -service name is not a preference: renaming one process merges two services in every dashboard built -on them, and a shell profile that names the app it was written for should not be able to do that to -T3 Code. Use `OTEL_RESOURCE_ATTRIBUTES` to tell instances apart, which is what it is for: - -```bash -export OTEL_RESOURCE_ATTRIBUTES=service.instance.id=laptop-01,deployment.environment=lab -``` - -A `service.namespace` in `OTEL_RESOURCE_ATTRIBUTES` is overridden the same way, since it is part of -the same identity. - -On macOS, ambient variables reach the desktop app only when it is launched from a shell. Opening it -from the Dock, Finder, or Spotlight inherits `launchd`'s environment instead, which is why the -instrumented walkthrough above launches from the same shell that exported the variables. Settings -and `T3CODE_OTLP_*` are not affected, and the server the desktop app spawns inherits whatever the -main process was given. - -#### Precedence - -For each signal, the first source that names its endpoint wins: - -1. `T3CODE_OTLP_*` -2. `OTEL_*` -3. the desktop bootstrap envelope -4. Settings, under `observability` - -An exported variable outranks a stored one, and T3 Code's own spelling of a variable outranks the -standard spelling of it. That is the same order `T3CODE_OTEL_SDK_DISABLED` and `OTEL_SDK_DISABLED` -follow, and it means an ambient `OTEL_EXPORTER_OTLP_ENDPOINT` overrides an endpoint saved in -Settings. Name a `T3CODE_OTLP_*` URL when you want a stored endpoint that nothing on the machine can -redirect. - -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`, `T3CODE_OTLP_HEADERS`, and -`T3CODE_OTLP_PROTOCOL` are the exceptions: they belong to no single signal and configure every -endpoint T3 Code's own names, the desktop bootstrap envelope, or Settings placed, wherever it -goes. - -The three signals are resolved separately, so traces can come from one source and metrics or logs -from another. - -A source that wins a signal can also decide not to export it. `OTEL_{TRACES,METRICS,LOGS}_EXPORTER=none`, -a list naming an exporter T3 Code does not have, and `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_PROTOCOL=grpc` -each turn off the signal they describe, and an endpoint in the bootstrap envelope or Settings does not -take it back over, because the exported variable is the more recent answer. A `T3CODE_OTLP_*` URL still -outranks all of it, since it names a different collector than the one those variables were describing. -The exporter list is read only for a signal the standard variables pointed somewhere, so -`OTEL_LOGS_EXPORTER=none` on a machine that exports no `OTEL_*` endpoint says nothing about a logs -endpoint saved in Settings. - -Whether anything is exported at all is one setting, read in that same order: `T3CODE_OTEL_SDK_DISABLED` -answers it, and `OTEL_SDK_DISABLED` answers it only when T3 Code's own name is unset. Either way the -answer stops every export, including one configured through Settings, which is the one switch a shared -machine needs. Reading ours first is what lets `T3CODE_OTEL_SDK_DISABLED=false` override an ambient -`OTEL_SDK_DISABLED=true`, so a machine can disable every other SDK and still ask for T3 Code's -telemetry. - -#### What Is Read - -| Variable | Effect | -| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | -| `OTEL_SDK_DISABLED` | Stops all export, unless `T3CODE_OTEL_SDK_DISABLED` answered first | -| `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_VERSION`, `OTEL_RESOURCE_ATTRIBUTES` | Resource identity attached to every span, metric, and log record | -| `OTEL_SERVICE_NAME` | Refused with a warning; service names are static | -| `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` (default), `delta`, or `lowmemory`, which resolves to `delta` | - -The wire format defaults to `http/protobuf` when the endpoint came from `OTEL_*`, matching the -specification, and follows `T3CODE_OTLP_PROTOCOL` otherwise, which defaults to `http/json`. - -`OTEL_EXPORTER_OTLP_PROTOCOL=grpc` is refused rather than downgraded, because T3 Code has no gRPC -transport and posting an HTTP body to a gRPC endpoint fails in a way that is harder to read than -exporting nothing. The refusal is logged at startup and turns off only the signal that named gRPC, -and only when these variables are the ones that named where it goes. - -Header and resource-attribute values are percent decoded, so -`OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20abc` sends the space and a base64 credential keeps -its `=` padding. - -#### Known Gaps - -Not everything in the specification is implemented. These are the ones worth knowing about: - -- **No gRPC.** `OTEL_EXPORTER_OTLP_PROTOCOL=grpc` is refused rather than downgraded, because T3 Code - has no gRPC transport and posting an HTTP body to a gRPC endpoint fails in a way that is - harder to read than exporting nothing. The refusal is logged at startup and turns off only the - signal that named gRPC, so `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=grpc` leaves traces exporting. -- **No exporter but OTLP.** `OTEL_{TRACES,METRICS,LOGS}_EXPORTER` accepts `otlp` and `none`. A list - that names `console`, `logging`, `zipkin`, `jaeger`, or `prometheus` and not `otlp` is read as a - deliberate "not this one", so that signal is not exported and the name that did it is logged. A - list that names nothing recognizable is treated as the typo it probably is: it is reported and - ignored, and the signal keeps exporting, because reading `otlpp` as "not OTLP" would turn one - transposed letter into a signal that stops with nothing in the log to connect the two. A list that - names `otlp` is exported over OTLP and reported for the rest, since `otlp,otlpp` would otherwise - look like a list where both entries took. -- **No compression and no client TLS.** `OTEL_EXPORTER_OTLP_COMPRESSION`, - `OTEL_EXPORTER_OTLP_CERTIFICATE`, `OTEL_EXPORTER_OTLP_CLIENT_KEY`, and - `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,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. -- **Only an `OTEL_*` metrics endpoint can choose its aggregation.** - `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` configures the metrics signal, and by the rule - in Precedence above a signal is configured by whichever source named its endpoint. A metrics - endpoint that came from `T3CODE_OTLP_METRICS_URL`, the desktop bootstrap envelope, or Settings - therefore exports `cumulative`, and there is no `T3CODE_*` spelling of this preference to change - that. This matters for one class of backend: a receiver that accepts delta histograms only, which - is how Datadog's OTLP intake behaves, drops cumulative histograms without reporting an error, so - every `_duration` timer would go missing while the `_total` counters kept arriving. Point such a - backend at `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` and set the preference to `delta`. -- **`lowmemory` temporality cannot be expressed per instrument kind.** It asks for delta on - synchronous counters and histograms and cumulative on the rest, and one temporality is applied to - every instrument here. It resolves to `delta` with a warning, which is what it asks for on the - counters and timers T3 Code actually records. Like every variable in this group it applies only - to the endpoint these variables named, so the warning is silent on a machine where they named no - metrics endpoint at all. -- **`OTEL_SERVICE_VERSION` is not a specification variable.** It is read as a convenience because - the exporter library reads it too. `OTEL_RESOURCE_ATTRIBUTES=service.version=...` is the portable - spelling. -- **`service.name` cannot be set from the environment.** See Which Processes Export above. - `OTEL_SERVICE_NAME` and `OTEL_RESOURCE_ATTRIBUTES=service.name=...` are both refused with a - warning, and the `service.name` key is dropped rather than passed through so the exporter never - receives two of them. - -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 - -A variable T3 Code cannot act on never stops it from starting. Two things can happen instead, -and both are logged once at startup: - -- **A warning, then the default.** A misspelled protocol, a temporality that is not a preference, - an exporter name the specification does not define, a timeout or batch size that is not a whole - number, a batch size of zero, or a pair list that is not valid percent encoding is reported and - ignored, and everything else keeps exporting. One bad value never costs you the other variables. - A batch size of zero is singled out because the exporter would meet that threshold on every record - and post one HTTP request per span, which takes a collector down rather than merely reading oddly; - a schedule delay of zero is a real request to drain as fast as the loop allows and is honored. -- **Export off.** `OTEL_EXPORTER_OTLP_PROTOCOL=grpc` and an `OTEL_{TRACES,METRICS,LOGS}_EXPORTER` - that names a specified exporter T3 Code has no implementation of do this, because each names - something T3 Code does not have rather than a value it failed to parse, and reporting the request - and then exporting anyway would be answering a different question than the one asked. - -An empty value means the same thing as an unset one, so `OTEL_SERVICE_VERSION=` reads as if the -variable were not there at all. An empty `OTEL_SERVICE_NAME` is not an attempt to rename anything, -so it is not warned about either. `OTEL_SDK_DISABLED` follows the specification's one rule for -booleans: the case-insensitive string `true` is the only value that switches export off, and -anything else, including `yes` and `1`, leaves it on. `T3CODE_OTEL_SDK_DISABLED` is T3 Code's own -name, so it takes `true`, `1`, `yes`, `on` and their negatives, and a value it cannot read is reported -and then left to `OTEL_SDK_DISABLED` to answer rather than treated as either answer itself. - -A `OTEL_EXPORTER_OTLP_HEADERS` or `OTEL_RESOURCE_ATTRIBUTES` value is discarded whole rather than -partly, whether a member fails to decode or carries no `key=value` pair at all. Keeping the members -that did parse is what makes a bad variable read like a bad token: the collector answers a -half-parsed credential with the same authentication error a wrong one gets, and -`authorization=token,x-tenant` would have authenticated and then routed to the wrong tenant. A -trailing or doubled comma is spacing, not a member, so `authorization=token,` is read as the one -pair it contains. - -These variables configure a signal only when they also supplied its endpoint. A `T3CODE_OTLP_*` -name winning the URL takes the whole signal with it, and so does the desktop bootstrap envelope or -Settings winning it for a signal these variables said nothing about, 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, 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, `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 ### Start With The Local Trace File @@ -747,14 +523,12 @@ It provides: - Effect trace-level and timing refs The desktop main process is a second producer, assembled in -`apps/desktop/src/app/DesktopObservability.ts`, and covers work the backend cannot see: app startup, -window and menu handling, backend supervision, and updates. It resolves its endpoints in -`apps/desktop/src/app/DesktopOtlpExport.ts`, reading the same `T3CODE_OTLP_*` names and Settings -entries as the backend it supervises, and the `OTEL_*` variables through the same -`packages/shared/src/otelEnvironment.ts` the server uses, so neither process can disagree with the -other about what a variable means. It reports as service `t3code-desktop`, which no variable can change, so a -collector shows it alongside the backend rather than mixed into it. It exports traces and logs only; the main process -records no metrics, so the metrics endpoint applies to the backend alone. +`apps/desktop/src/app/DesktopObservability.ts`. It reads the same `T3CODE_OTLP_*` names and the same +Settings entries as the backend it supervises, and covers work the backend cannot see: app startup, +window and menu handling, backend supervision, and updates. It reports as service +`t3code-desktop`, so a collector shows it alongside the backend rather than mixed into it. It +exports traces and logs only; the main process records no metrics, so the metrics endpoint applies +to the backend alone. ### Env Vars @@ -776,13 +550,40 @@ OTLP export: - `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` -- `T3CODE_OTEL_SDK_DISABLED`: stops every export, whatever configured it, including Settings. Read - before `OTEL_SDK_DISABLED`, so `false` here keeps T3 Code exporting on a machine that sets the - standard name. + +The server and the desktop app also read the standard +`OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` and generic `OTEL_EXPORTER_OTLP_ENDPOINT` (with +`/v1/traces`, `/v1/metrics`, or `/v1/logs` appended), for a collector expecting those instead. A +non-blank `T3CODE_OTLP_*_URL` wins over either, and a per-signal endpoint wins over the generic one +for its signal. A blank value counts as unset. A signal with an OTEL endpoint takes its headers from +`OTEL_EXPORTER_OTLP_HEADERS` and its protocol from `OTEL_EXPORTER_OTLP_PROTOCOL` (default +`http/protobuf`, read case-insensitively), and a per-signal +`OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` or `_PROTOCOL` wins over the generic one for its +signal. `T3CODE_OTLP_HEADERS` and `T3CODE_OTLP_PROTOCOL` never apply to it. An endpoint that is not +an `http` or `https` URL, a protocol other than `http/protobuf` or `http/json` such as `grpc`, or +headers that are not `key=value` pairs with percent-encoded values turn that signal's export off +with a startup warning, rather than sending it to the Settings endpoint. + +Service names are fixed: `t3code-server` for the backend and `t3code-desktop` for the desktop main +process, both in `service.namespace` `t3code`. `OTEL_SERVICE_NAME` and a `service.name` or +`service.namespace` in `OTEL_RESOURCE_ATTRIBUTES` are ignored. Tell installations apart with other +resource attributes, such as `OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=development`. If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay on stdout only. +### The Kill Switch + +`T3CODE_OTEL_SDK_DISABLED` and `OTEL_SDK_DISABLED` turn off every OTLP export in both the server and +the desktop main process, overriding any endpoint from the environment or Settings. Local trace +files and stdout logs are unaffected. + +`T3CODE_OTEL_SDK_DISABLED` wins when set, so `T3CODE_OTEL_SDK_DISABLED=false` re-enables export on a +machine that sets `OTEL_SDK_DISABLED` for everything else. It accepts the usual boolean spellings +(`true`/`false`, `yes`/`no`, `on`/`off`, `1`/`0`, `y`/`n`). `OTEL_SDK_DISABLED` follows the +OpenTelemetry specification and only `true` disables export, so `OTEL_SDK_DISABLED=1` does not. +Values are case-insensitive and trimmed. An unrecognized value is ignored with a startup warning. + ### What Is Instrumented Today Current high-value span and metric boundaries include: diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index ee8afb42aeec..9112896eb1c9 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -137,19 +137,6 @@ 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/settings.test.ts b/packages/contracts/src/settings.test.ts index 480057a0b629..16b9418ab7cc 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -902,8 +902,6 @@ 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: { @@ -924,8 +922,6 @@ 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/shared/src/observability.ts b/packages/shared/src/observability.ts index 62121399853b..6393ed0879db 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -16,6 +16,25 @@ export type OtlpProtocol = typeof OtlpProtocol.Type; export const otlpSerializationLayer = (protocol: OtlpProtocol) => protocol === "http/protobuf" ? OtlpSerialization.layerProtobuf : OtlpSerialization.layerJson; +/** + * How one signal is exported, once whichever source named that signal's + * endpoint has been resolved. Held per signal rather than per process, so a + * wire format or a credential cannot be paired by hand with an endpoint that + * came from somewhere else. + */ +export interface SignalExport { + readonly protocol: OtlpProtocol; + readonly headers: Readonly> | undefined; + readonly exportIntervalMs: number; +} + +/** What T3 Code exports with when nothing configured a signal. */ +export const DEFAULT_SIGNAL_EXPORT: SignalExport = { + protocol: "http/json", + headers: undefined, + exportIntervalMs: 10_000, +}; + const FLUSH_BUFFER_THRESHOLD = 256; const textEncoder = new TextEncoder(); diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts index 3b1692e1bc6f..cd4e683faa6b 100644 --- a/packages/shared/src/otelEnvironment.test.ts +++ b/packages/shared/src/otelEnvironment.test.ts @@ -6,994 +6,79 @@ import * as OtlpResource from "effect/unstable/observability/OtlpResource"; import * as OtelEnvironment from "./otelEnvironment.ts"; -const withEnv = (env: Record) => - Effect.provide(Layer.mergeAll(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); +const load = (env: Record) => + OtelEnvironment.load.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); -const load = (env: Record) => OtelEnvironment.load.pipe(withEnv(env)); +const SPEC_OFF = + "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway"; +const T3_OFF = + "T3CODE_OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it"; +const specIgnored = (value: string) => + `OTEL_SDK_DISABLED=${value} was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way`; describe("OtelEnvironment", () => { - it.effect("stays off when nothing is configured", () => - Effect.gen(function* () { - 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); - }), - ); - - it.effect("appends the signal path to the generic endpoint", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com" }), - ); - assert.strictEqual(resolved.traces.settings?.url, "https://collector.example.com/v1/traces"); - assert.strictEqual( - resolved.metrics.settings?.url, - "https://collector.example.com/v1/metrics", - ); - assert.strictEqual(resolved.logs.settings?.url, "https://collector.example.com/v1/logs"); - }), - ); - - it.effect("does not double the slash on a generic endpoint that has one", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com/" }), - ); - assert.strictEqual(resolved.traces.settings?.url, "https://collector.example.com/v1/traces"); - }), - ); - - it.effect("takes a signal endpoint exactly as written", () => - Effect.gen(function* () { - // The per-signal variable is a whole URL. Appending to it would send - // traces to a path the collector does not serve. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://generic.example.com", - OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://traces.example.com/ingest", - }), - ); - assert.strictEqual(resolved.traces.settings?.url, "https://traces.example.com/ingest"); - assert.strictEqual(resolved.metrics.settings?.url, "https://generic.example.com/v1/metrics"); - }), - ); - - it.effect("exports without OTEL_TRACES_EXPORTER, because otlp is its default", () => - Effect.gen(function* () { - // A machine that sets OTEL_METRICS_EXPORTER and leaves the traces one - // alone still wants traces; the spec default is otlp, not none. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_METRICS_EXPORTER: "otlp", - }), - ); - assert.isDefined(resolved.traces.settings); - }), - ); - - it.effect("honors a signal turned off by name", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_TRACES_EXPORTER: "none", - }), - ); - assert.strictEqual(resolved.traces.settings, undefined); - assert.isDefined(resolved.metrics.settings); - }), - ); - - it.effect("finds otlp in a list of exporters", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_TRACES_EXPORTER: "console, otlp", - }), - ); - assert.isDefined(resolved.traces.settings); - assert.isTrue(resolved.warnings.some((warning) => warning.includes("console, otlp"))); - }), - ); - - it.effect("exports nothing when the SDK is disabled", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_SDK_DISABLED: "true", - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - }), - ); - assert.strictEqual(resolved.disabled, true); - assert.strictEqual(resolved.traces.settings, undefined); - assert.strictEqual(resolved.metrics.settings, undefined); - // Someone who inherited this from a shell profile has somewhere to go. - assert.include(resolved.warnings.join("\n"), "T3CODE_OTEL_SDK_DISABLED=false"); - }), - ); - - it.effect("lets T3 Code's own name answer before the standard one", () => - Effect.gen(function* () { - const off = yield* OtelEnvironment.load.pipe( - withEnv({ - T3CODE_OTEL_SDK_DISABLED: "true", - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - }), - ); - assert.isTrue(off.disabled); - assert.strictEqual(off.traces.settings, undefined); - assert.deepStrictEqual(off.warnings, [ - "T3CODE_OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it", - ]); - - // The point of reading ours first: a machine that disables every other - // SDK in its shell profile can still ask for T3 Code's telemetry. - const on = yield* OtelEnvironment.load.pipe( - withEnv({ - T3CODE_OTEL_SDK_DISABLED: "false", - OTEL_SDK_DISABLED: "true", - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - }), - ); - assert.isFalse(on.disabled); - assert.isDefined(on.traces.settings); - assert.deepStrictEqual(on.warnings, []); - }), - ); - - it.effect("reads T3 Code's own name the way T3 Code reads a boolean", () => - Effect.gen(function* () { - // Ours to define, so it takes the affirmatives people type. The - // specification's single-value rule stays with the OTEL_* name. - const numeric = yield* OtelEnvironment.load.pipe(withEnv({ T3CODE_OTEL_SDK_DISABLED: "1" })); - assert.isTrue(numeric.disabled); - - // A value that answers nothing leaves the source under it to answer. - const nonsense = yield* OtelEnvironment.load.pipe( - withEnv({ - T3CODE_OTEL_SDK_DISABLED: "maybe", - OTEL_SDK_DISABLED: "true", - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - }), - ); - assert.isTrue(nonsense.disabled); - assert.include(nonsense.warnings.join("\n"), "T3CODE_OTEL_SDK_DISABLED=maybe"); - }), - ); - - it.effect("carries the headers a collector needs to accept the request", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_HEADERS: "api-key=abc123,x-tenant=acme", - OTEL_EXPORTER_OTLP_TRACES_HEADERS: "api-key=traces-only", - }), - ); - // The per-signal header set replaces the generic one rather than - // merging with it, which is what the spec says and what a collector - // with two different keys depends on. - assert.deepStrictEqual(resolved.traces.settings?.headers, { "api-key": "traces-only" }); - assert.deepStrictEqual(resolved.metrics.settings?.headers, { - "api-key": "abc123", - "x-tenant": "acme", - }); - }), - ); - - it.effect("reads the service version and the leftover resource attributes", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_RESOURCE_ATTRIBUTES: "org.name=Example,deployment=prod", - OTEL_SERVICE_VERSION: "1.2.3", - }), - ); - assert.strictEqual(resolved.serviceVersion, "1.2.3"); - // service.version becomes a named field, so leaving it in the attribute - // bag too would send it twice. - assert.deepStrictEqual(resolved.resourceAttributes, { - "org.name": "Example", - deployment: "prod", - }); - }), - ); - - it.effect("refuses to rename the service, and says so", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_SERVICE_NAME: "some-other-app" }), - ); - assert.deepStrictEqual(resolved.resourceAttributes, {}); - assert.lengthOf(resolved.warnings, 1); - assert.include(resolved.warnings[0] ?? "", "OTEL_SERVICE_NAME was ignored"); - }), - ); - - it.effect("drops a service.name hidden in the resource attributes", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_RESOURCE_ATTRIBUTES: "service.name=some-other-app,host.name=lab-01" }), - ); - // Dropped rather than passed through, or the exporter would receive a - // second service.name beside the one the process chose. - assert.deepStrictEqual(resolved.resourceAttributes, { "host.name": "lab-01" }); - assert.include(resolved.warnings[0] ?? "", "service.name was ignored"); - }), - ); - - it.effect("names OTEL_SERVICE_NAME rather than the attribute when both are set", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_SERVICE_NAME: "explicit", - OTEL_RESOURCE_ATTRIBUTES: "service.name=from-attributes", - }), - ); - assert.lengthOf(resolved.warnings, 1); - assert.include(resolved.warnings[0] ?? "", "OTEL_SERVICE_NAME was ignored"); - }), - ); - - it.effect("declines grpc instead of posting a body it cannot frame", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_PROTOCOL: "grpc", - }), - ); - assert.strictEqual(resolved.traces.settings, undefined); - assert.strictEqual(resolved.metrics.settings, undefined); - assert.include(resolved.traces.declined ?? "", "grpc"); - }), - ); - - it.effect("declines only the signal that asked for grpc", () => - Effect.gen(function* () { - // A metric endpoint that speaks gRPC says nothing about where traces go, - // and turning traces off over it loses telemetry nobody asked to lose. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_METRICS_PROTOCOL: "grpc", - }), - ); - assert.isDefined(resolved.traces.settings); - assert.strictEqual(resolved.metrics.settings, undefined); - assert.include(resolved.metrics.declined ?? "", "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"); - }), - ); - - it.effect("defaults each signal to the specification's wire format", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com" }), - ); - assert.strictEqual(resolved.traces.settings?.protocol, "http/protobuf"); - assert.strictEqual(resolved.metrics.settings?.protocol, "http/protobuf"); - assert.strictEqual(resolved.logs.settings?.protocol, "http/protobuf"); - }), - ); - - it.effect("keeps the wire format on the signal that named an endpoint", () => - Effect.gen(function* () { - // A protocol with no endpoint of its own describes nothing, so it must - // not reach an export configured by some other name. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf" }), - ); - assert.strictEqual(resolved.traces.settings, undefined); - assert.strictEqual(resolved.metrics.settings, undefined); - }), - ); - - it.effect("takes the batch knobs the exporter can act on", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - 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); - }), - ); - - it.effect("leaves the request timeouts alone rather than spending them on shutdown", () => - Effect.gen(function* () { - // These name a per-request deadline and the exporter has no such knob. - // Bounding the final flush with them instead would hold a restart open - // for as long as the collector was allowed to be slow. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_TIMEOUT: "600000", - OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: "600000", - OTEL_METRIC_EXPORT_TIMEOUT: "600000", - }), - ); - assert.deepStrictEqual(Object.keys(resolved.traces.settings ?? {}).sort(), [ - "exportIntervalMs", - "headers", - "maxBatchSize", - "protocol", - "temporality", - "url", - ]); - assert.deepStrictEqual(resolved.warnings, []); - }), - ); - - it.effect("falls back to the specification's own batching defaults", () => - Effect.gen(function* () { - // Once this route is the one configuring the exporter, the numbers that - // apply are the specification's, not the ones T3 Code picked for itself. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com" }), - ); - 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); - }), - ); - - it.effect("lets the metric signal name its own aggregation", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "delta", - }), - ); - assert.strictEqual(resolved.metrics.settings?.temporality, "delta"); - assert.strictEqual(resolved.traces.settings?.temporality, undefined); - assert.strictEqual(resolved.logs.settings?.temporality, undefined); - }), - ); - - it.effect("decodes a header the way the specification encodes it", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Bearer%20abc123, x-scope=team%2Fplatform", - }), - ); - assert.deepStrictEqual(resolved.traces.settings?.headers, { - Authorization: "Bearer abc123", - "x-scope": "team/platform", - }); - }), - ); - - it.effect("keeps a credential that contains its own separator", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Basic YWJjOmRlZg==", - }), - ); - assert.deepStrictEqual(resolved.traces.settings?.headers, { - Authorization: "Basic YWJjOmRlZg==", - }); - }), - ); - - it.effect("decodes resource attributes too", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_RESOURCE_ATTRIBUTES: "team=platform%20eng,deployment.environment=prod" }), - ); - assert.deepStrictEqual(resolved.resourceAttributes, { - team: "platform eng", - "deployment.environment": "prod", - }); - }), - ); - - it.effect("discards a pair list that is not valid percent encoding", () => - Effect.gen(function* () { - // Half a header set is worse than none: the collector answers a partial - // credential with the same 401 it gives a wrong one, and nothing says - // the variable was the problem. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_HEADERS: "x-token=100%zz,x-other=100%25", - }), - ); - assert.strictEqual(resolved.traces.settings?.headers, undefined); - assert.isTrue( - resolved.warnings.some((warning) => warning.includes("OTEL_EXPORTER_OTLP_HEADERS")), - ); - }), - ); - - it.effect("discards resource attributes that do not decode", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_RESOURCE_ATTRIBUTES: "team=100%zz,deployment=prod" }), - ); - assert.deepStrictEqual(resolved.resourceAttributes, {}); - assert.isTrue( - resolved.warnings.some((warning) => warning.includes("OTEL_RESOURCE_ATTRIBUTES")), - ); - }), - ); - - it.effect("appends the signal path after a base that already has one", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com/otel" }), - ); - assert.strictEqual( - resolved.traces.settings?.url, - "https://collector.example.com/otel/v1/traces", - ); - }), - ); - - it.effect("resolves lowmemory to the aggregation it asks for on these metrics", () => - Effect.gen(function* () { - // Falling back to the default here would invert the request rather than - // decline it, and invert it toward the value a delta-only receiver drops - // without an error, so the timers would vanish and the counters would not. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "lowmemory", - }), - ); - assert.strictEqual(resolved.metrics.settings?.temporality, "delta"); - assert.isTrue(resolved.warnings.some((warning) => warning.includes("lowmemory"))); - }), - ); - - it.effect("says nothing about an aggregation for metrics these variables did not place", () => - Effect.gen(function* () { - // The preference travels with the endpoint that asked for it, so on a - // machine whose metrics endpoint comes from somewhere else this warning - // would claim an aggregation that never applied. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://collector.example.com/v1/traces", - OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "lowmemory", - }), - ); - assert.strictEqual(resolved.metrics.settings, undefined); - assert.isFalse(resolved.warnings.some((warning) => warning.includes("lowmemory"))); - }), - ); - - it.effect("ignores a temporality that is not a preference at all", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "hourly", - }), - ); - assert.isDefined(resolved.metrics.settings); - assert.strictEqual(resolved.metrics.settings?.temporality, undefined); - assert.isTrue(resolved.warnings.some((warning) => warning.includes("hourly"))); - }), - ); - - it.effect("asks for no aggregation when nothing names one", () => - Effect.gen(function* () { - // Left unset on purpose. The exporter applies - // `DEFAULT_METRICS_TEMPORALITY`, and a value here would claim the - // operator chose it. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com" }), - ); - assert.isDefined(resolved.metrics.settings); - assert.strictEqual(resolved.metrics.settings?.temporality, undefined); - assert.deepStrictEqual(resolved.warnings, []); - }), - ); - - it.effect("warns about a misspelled protocol and keeps exporting", () => - Effect.gen(function* () { - // The specification is explicit here: a value the implementation does - // not recognize gets a warning and is ignored. Switching export off over - // a typo loses the telemetry the typo was not about. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_PROTOCOL: "htp/json", - }), - ); - assert.strictEqual(resolved.traces.settings?.protocol, "http/protobuf"); - assert.strictEqual(resolved.traces.declined, undefined); - assert.isTrue(resolved.warnings.some((warning) => warning.includes("htp/json"))); - }), - ); - - it.effect("lets the two signals use different wire formats", () => - Effect.gen(function* () { - // Each signal builds its own serializer, so the metric protocol is - // honored on its own rather than losing to the trace one. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_PROTOCOL: "http/json", - OTEL_EXPORTER_OTLP_METRICS_PROTOCOL: "http/protobuf", - }), - ); - assert.strictEqual(resolved.traces.settings?.protocol, "http/json"); - assert.strictEqual(resolved.metrics.settings?.protocol, "http/protobuf"); - assert.deepStrictEqual(resolved.warnings, []); - }), - ); - - it.effect("keeps exporting when a number is not a number", () => - Effect.gen(function* () { - // A typo on one knob must not take the rest of the telemetry with it. - // Before this, the read failed outright and nothing was exported. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_BSP_SCHEDULE_DELAY: "abc", - }), - ); - assert.strictEqual(resolved.traces.settings?.exportIntervalMs, 5000); - assert.strictEqual( - resolved.metrics.settings?.url, - "https://collector.example.com/v1/metrics", - ); - assert.isTrue( - resolved.warnings.some((warning) => warning.includes("OTEL_BSP_SCHEDULE_DELAY")), - ); - }), - ); - - it.effect("reads a boolean the way the specification defines one", () => - Effect.gen(function* () { - // Case insensitive `true` and nothing else. `yes` is affirmative in - // other config systems and false here, which the specification is - // explicit about. - const upper = yield* OtelEnvironment.load.pipe(withEnv({ OTEL_SDK_DISABLED: "True" })); - assert.isTrue(upper.disabled); - - const affirmative = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_SDK_DISABLED: "yes", - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - }), - ); - assert.isFalse(affirmative.disabled); - assert.isDefined(affirmative.traces.settings); - }), - ); - - it.effect("treats an empty value as an unset one", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_SERVICE_NAME: "", - OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "", - }), - ); - // An empty rename is not a rename, so it is not worth a warning either. - assert.deepStrictEqual(resolved.warnings, []); - assert.strictEqual(resolved.traces.settings?.url, "https://collector.example.com/v1/traces"); - }), - ); - - it.effect("falls back to the generic headers when the signal's own list is junk", () => - Effect.gen(function* () { - // A list with no pair in it is malformed, not a request for no headers. - // Reading it as an answer would shadow the generic variable and send an - // unauthenticated stream to a collector that was told how to authorize. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_TRACES_HEADERS: "junk", - OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Bearer%20abc123", - }), - ); - assert.deepStrictEqual(resolved.traces.settings?.headers, { - Authorization: "Bearer abc123", - }); - assert.isTrue( - resolved.warnings.some((warning) => warning.includes("OTEL_EXPORTER_OTLP_TRACES_HEADERS")), - ); - }), - ); - - it.effect("names a shared variable once even though both signals read it", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_HEADERS: "x-token=100%zz", - }), - ); - assert.strictEqual( - resolved.warnings.filter((warning) => warning.includes("OTEL_EXPORTER_OTLP_HEADERS")) - .length, - 1, - ); - }), - ); - - it.effect("does not blame gRPC for a signal that was never going to export", () => - Effect.gen(function* () { - // Nothing named an endpoint, so the protocol is beside the point and - // reporting it would send someone looking for a collector problem. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" }), - ); - assert.strictEqual(resolved.traces.declined, undefined); - assert.strictEqual(resolved.metrics.declined, undefined); - }), - ); - - it.effect("stays quiet about the protocol once the SDK is off", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_SDK_DISABLED: "true", - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_PROTOCOL: "grpc", - }), - ); - assert.isTrue(resolved.disabled); - assert.strictEqual(resolved.traces.declined, undefined); - assert.strictEqual(resolved.metrics.declined, undefined); - }), - ); - - it.effect("does not carry a padded variable into the URL it builds", () => - Effect.gen(function* () { - // A shell profile that lined up its exports did not mean the padding to - // become part of the endpoint, and the appended signal path would put it - // in the middle of the URL where nothing would report it. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: " https://collector.example.com/ ", - OTEL_SERVICE_VERSION: " 1.2.3 ", - }), - ); - assert.strictEqual(resolved.traces.settings?.url, "https://collector.example.com/v1/traces"); - assert.strictEqual(resolved.serviceVersion, "1.2.3"); - }), - ); - - it.effect("reads the metric protocol when it is the only one named", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_METRICS_PROTOCOL: "http/json", - }), - ); - assert.strictEqual(resolved.metrics.settings?.protocol, "http/json"); - assert.strictEqual(resolved.traces.settings?.protocol, "http/protobuf"); - 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); - }), - ); - - it.effect("keeps exporting when an exporter name is misspelled", () => - Effect.gen(function* () { - // Reading `otlpp` as "not OTLP" would turn one transposed letter into a - // signal that stops exporting with nothing to connect the two. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_TRACES_EXPORTER: "otlpp", - }), - ); - assert.isDefined(resolved.traces.settings); - assert.isTrue(resolved.warnings.some((warning) => warning.includes("OTEL_TRACES_EXPORTER"))); - }), - ); - - it.effect("stops exporting a signal that asked for an exporter this has none of", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_METRICS_EXPORTER: "prometheus", - }), - ); - assert.strictEqual(resolved.metrics.settings, undefined); - assert.isDefined(resolved.traces.settings); - assert.isTrue(resolved.warnings.some((warning) => warning.includes("OTEL_METRICS_EXPORTER"))); - }), - ); - - it.effect("says a misspelling beside otlp did nothing rather than passing it over", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_LOGS_EXPORTER: "otlp,otlpp", - }), - ); - assert.isDefined(resolved.logs.settings); - assert.isTrue(resolved.warnings.some((warning) => warning.includes("otlp,otlpp"))); - }), - ); - - it.effect("says nothing about an exporter list on a signal with nowhere to go", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ OTEL_TRACES_EXPORTER: "zipkin" }), - ); - assert.deepStrictEqual(resolved.warnings, []); - }), - ); - - it.effect("refuses a batch size of zero rather than posting one request per span", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_BSP_MAX_EXPORT_BATCH_SIZE: "0", - }), - ); - assert.strictEqual(resolved.traces.settings?.maxBatchSize, 512); - assert.isTrue( - resolved.warnings.some((warning) => warning.includes("OTEL_BSP_MAX_EXPORT_BATCH_SIZE")), - ); - }), - ); - - it.effect("still drains as fast as the loop allows on a delay of zero", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_BSP_SCHEDULE_DELAY: "0", - }), - ); - assert.strictEqual(resolved.traces.settings?.exportIntervalMs, 0); - }), - ); - - it.effect("discards a header list where one member carries no pair", () => - Effect.gen(function* () { - // Keeping the readable members would authorize the stream and then route - // it to the wrong tenant, which reads as a collector problem rather than - // as the typo it is. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_HEADERS: "authorization=token,x-tenant", - }), - ); - assert.strictEqual(resolved.traces.settings?.headers, undefined); - assert.isTrue( - resolved.warnings.some((warning) => warning.includes("OTEL_EXPORTER_OTLP_HEADERS")), - ); - }), - ); - - it.effect("keeps a stored endpoint from re-enabling a signal turned off by name", () => - Effect.gen(function* () { - // `none` is an answer about this signal, not an absence of one, so the - // endpoint someone saved once does not get to give the opposite answer. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_LOGS_EXPORTER: "none", - }), - ); - assert.strictEqual(resolved.logs.off, true); - const logs = OtelEnvironment.resolveSignalSource({ - t3Url: undefined, - signal: resolved.logs, - persistedUrl: "https://stored.example.com/v1/logs", - }); - assert.strictEqual(logs.url, undefined); - }), - ); - - it.effect("keeps a stored endpoint from answering for a declined transport", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "grpc", - }), - ); - assert.strictEqual(resolved.traces.off, true); - const traces = OtelEnvironment.resolveSignalSource({ - t3Url: undefined, - signal: resolved.traces, - persistedUrl: "https://stored.example.com/v1/traces", - }); - assert.strictEqual(traces.url, undefined); - assert.isDefined(traces.signal.declined); - }), - ); - - it.effect("still reaches the endpoint T3 Code's own name gave a signal turned off", () => - Effect.gen(function* () { - // T3 Code's own name outranks the standard names, so an operator who set - // it is not overruled by a fleet-wide exporter list. - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_METRICS_EXPORTER: "none", - }), - ); - const metrics = OtelEnvironment.resolveSignalSource({ - t3Url: "https://t3.example.com/v1/metrics", - signal: resolved.metrics, - persistedUrl: undefined, - }); - assert.strictEqual(metrics.url, "https://t3.example.com/v1/metrics"); - }), - ); - - it.effect("leaves a stored endpoint alone when no standard endpoint named the signal", () => - Effect.gen(function* () { - // With nowhere for these variables to send anything, the exporter list is - // not read at all, so it says nothing about the signal and cannot switch - // off an export it was never describing. - const resolved = yield* OtelEnvironment.load.pipe(withEnv({ OTEL_LOGS_EXPORTER: "none" })); - assert.strictEqual(resolved.logs.off, false); - const logs = OtelEnvironment.resolveSignalSource({ - t3Url: undefined, - signal: resolved.logs, - persistedUrl: "https://stored.example.com/v1/logs", - }); - assert.strictEqual(logs.url, "https://stored.example.com/v1/logs"); - }), - ); - - it.effect("reads a trailing comma as spacing rather than as a member", () => - Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_HEADERS: "authorization=token,", - }), - ); - assert.deepStrictEqual(resolved.traces.settings?.headers, { authorization: "token" }); - }), - ); - - it.effect("warns about a spec value the specification does not recognize", () => - Effect.gen(function* () { - const numeric = yield* OtelEnvironment.load.pipe(withEnv({ OTEL_SDK_DISABLED: "1" })); - assert.isFalse(numeric.disabled); - assert.deepStrictEqual(numeric.warnings, [ - "OTEL_SDK_DISABLED=1 was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way", - ]); - - // T3 Code's own name already answered, so the standard one is moot for - // `disabled`, but a value nobody can read is still worth a warning. - const answered = yield* OtelEnvironment.load.pipe( - withEnv({ T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "yes" }), - ); - assert.isFalse(answered.disabled); - assert.deepStrictEqual(answered.warnings, [ - "OTEL_SDK_DISABLED=yes was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way", - ]); - }), - ); - - it.effect("treats a blank T3CODE_OTEL_SDK_DISABLED as unset and falls through", () => + it.effect.each([ + { name: "nothing set", env: {}, disabled: false, warnings: [] }, + // OTEL_SDK_DISABLED follows the specification: only `true`, case-insensitively. + { name: "spec true", env: { OTEL_SDK_DISABLED: "true" }, disabled: true, warnings: [SPEC_OFF] }, + { name: "spec True", env: { OTEL_SDK_DISABLED: "True" }, disabled: true, warnings: [SPEC_OFF] }, + { + name: "spec padded", + env: { OTEL_SDK_DISABLED: " true " }, + disabled: true, + warnings: [SPEC_OFF], + }, + { name: "spec false", env: { OTEL_SDK_DISABLED: "false" }, disabled: false, warnings: [] }, + { + name: "spec 1", + env: { OTEL_SDK_DISABLED: "1" }, + disabled: false, + warnings: [specIgnored("1")], + }, + { + name: "spec padded yes", + env: { OTEL_SDK_DISABLED: " yes " }, + disabled: false, + warnings: [specIgnored("yes")], + }, + // T3CODE_OTEL_SDK_DISABLED takes Config.Boolean's values, case-insensitively. + { name: "t3 1", env: { T3CODE_OTEL_SDK_DISABLED: "1" }, disabled: true, warnings: [T3_OFF] }, + { + name: "t3 TRUE", + env: { T3CODE_OTEL_SDK_DISABLED: "TRUE" }, + disabled: true, + warnings: [T3_OFF], + }, + { name: "t3 n", env: { T3CODE_OTEL_SDK_DISABLED: "n" }, disabled: false, warnings: [] }, + { + name: "t3 false overrides spec true", + env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "true" }, + disabled: false, + warnings: [], + }, + { + name: "blank t3 falls through", + env: { T3CODE_OTEL_SDK_DISABLED: " ", OTEL_SDK_DISABLED: "true" }, + disabled: true, + warnings: [SPEC_OFF], + }, + { + name: "unreadable t3 warns and falls through", + env: { T3CODE_OTEL_SDK_DISABLED: "maybe", OTEL_SDK_DISABLED: "true" }, + disabled: true, + warnings: ["T3CODE_OTEL_SDK_DISABLED=maybe is not a yes or a no and was ignored", SPEC_OFF], + }, + { + name: "bad spec value still warns when t3 answered", + env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "yes" }, + disabled: false, + warnings: [specIgnored("yes")], + }, + ])("$name", ({ env, disabled, warnings }) => Effect.gen(function* () { - const resolved = yield* OtelEnvironment.load.pipe( - withEnv({ T3CODE_OTEL_SDK_DISABLED: " ", OTEL_SDK_DISABLED: "true" }), - ); - assert.isTrue(resolved.disabled); - assert.deepStrictEqual(resolved.warnings, [ - "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway", - ]); + const resolved = yield* load(env); + assert.strictEqual(resolved.disabled, disabled); + assert.deepStrictEqual(resolved.warnings, warnings); }), ); @@ -1021,6 +106,296 @@ describe("OtelEnvironment", () => { }), ); + describe("endpoints", () => { + const urlOf = (signal: OtelEnvironment.OtelSignal) => + OtelEnvironment.OtelSignal.$match(signal, { + Export: ({ url }) => url, + Off: () => "Off", + Unset: () => "Unset", + }); + it.effect.each([ + { + name: "nothing set", + env: {}, + traces: "Unset", + metrics: "Unset", + logs: "Unset", + warnings: [], + }, + { + name: "generic endpoint appends each signal's path, keeping the query", + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base?api_key=secret" }, + traces: "https://collector:4318/base/v1/traces?api_key=secret", + metrics: "https://collector:4318/base/v1/metrics?api_key=secret", + logs: "https://collector:4318/base/v1/logs?api_key=secret", + warnings: [], + }, + { + name: "a per-signal endpoint is used verbatim", + env: { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/custom" }, + traces: "https://tempo:4318/custom", + metrics: "Unset", + logs: "Unset", + warnings: [], + }, + { + name: "a per-signal endpoint beats the generic one", + env: { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/custom", + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", + }, + traces: "https://tempo:4318/custom", + metrics: "https://collector:4318/base/v1/metrics", + logs: "https://collector:4318/base/v1/logs", + warnings: [], + }, + { + name: "a blank per-signal endpoint falls through to the generic one", + env: { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: " ", + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", + }, + traces: "https://collector:4318/base/v1/traces", + metrics: "https://collector:4318/base/v1/metrics", + logs: "https://collector:4318/base/v1/logs", + warnings: [], + }, + { + name: "an invalid per-signal endpoint warns and does not fall through", + env: { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "not-a-url", + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", + }, + traces: "Off", + metrics: "https://collector:4318/base/v1/metrics", + logs: "https://collector:4318/base/v1/logs", + warnings: [ + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is not an http or https URL, so the signals it configures are not exported", + ], + }, + { + name: "an invalid generic endpoint warns without leaking its query", + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "not-a-url?api_key=secret" }, + traces: "Off", + metrics: "Off", + logs: "Off", + warnings: [ + "OTEL_EXPORTER_OTLP_ENDPOINT is not an http or https URL, so the signals it configures are not exported", + ], + }, + { + name: "an endpoint without a scheme is not an http URL", + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "localhost:4318" }, + traces: "Off", + metrics: "Off", + logs: "Off", + warnings: [ + "OTEL_EXPORTER_OTLP_ENDPOINT is not an http or https URL, so the signals it configures are not exported", + ], + }, + { + name: "the kill switch wins outright over a valid endpoint", + env: { + T3CODE_OTEL_SDK_DISABLED: "true", + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", + }, + traces: "Unset", + metrics: "Unset", + logs: "Unset", + warnings: [T3_OFF], + }, + ])("$name", ({ env, traces, metrics, logs, warnings }) => + Effect.gen(function* () { + const resolved = yield* load(env); + assert.strictEqual(urlOf(resolved.traces), traces); + assert.strictEqual(urlOf(resolved.metrics), metrics); + assert.strictEqual(urlOf(resolved.logs), logs); + assert.deepStrictEqual(resolved.warnings, warnings); + }), + ); + + const ENDPOINT = { OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318" }; + const exportOf = (signal: OtelEnvironment.OtelSignal): unknown => + OtelEnvironment.OtelSignal.$match(signal, { + Export: ({ protocol, headers }) => ({ protocol, headers }), + Off: () => "Off", + Unset: () => "Unset", + }); + it.effect.each([ + { + name: "nothing else set takes the specification's default protocol", + env: ENDPOINT, + traces: { protocol: "http/protobuf", headers: undefined }, + logs: { protocol: "http/protobuf", headers: undefined }, + warnings: [], + }, + { + name: "headers are comma-separated pairs with percent-encoded values", + env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS: "api-key=a%20b,tenant=t3" }, + traces: { protocol: "http/protobuf", headers: { "api-key": "a b", tenant: "t3" } }, + logs: { protocol: "http/protobuf", headers: { "api-key": "a b", tenant: "t3" } }, + warnings: [], + }, + { + name: "a per-signal protocol and headers beat the generic ones", + env: { + ...ENDPOINT, + OTEL_EXPORTER_OTLP_PROTOCOL: "http/json", + OTEL_EXPORTER_OTLP_HEADERS: "api-key=shared", + OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_TRACES_HEADERS: "api-key=traces%20only", + }, + traces: { protocol: "http/protobuf", headers: { "api-key": "traces only" } }, + logs: { protocol: "http/json", headers: { "api-key": "shared" } }, + warnings: [], + }, + { + name: "an unsupported protocol turns off the signals it configures", + env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc" }, + traces: { protocol: "http/protobuf", headers: undefined }, + logs: "Off", + warnings: [ + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL is not http/protobuf or http/json, so the signals it configures are not exported", + ], + }, + { + name: "a protocol reads case-insensitively", + env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL: "HTTP/JSON" }, + traces: { protocol: "http/json", headers: undefined }, + logs: { protocol: "http/json", headers: undefined }, + warnings: [], + }, + { + name: "undecodable headers turn off every signal once, without leaking them", + env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz" }, + traces: "Off", + logs: "Off", + warnings: [ + "OTEL_EXPORTER_OTLP_HEADERS is not a list of key=value pairs with percent-encoded values, so the signals it configures are not exported", + ], + }, + { + name: "a header without a value separator turns its signal off", + env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_HEADERS: "Authorization" }, + traces: { protocol: "http/protobuf", headers: undefined }, + logs: "Off", + warnings: [ + "OTEL_EXPORTER_OTLP_LOGS_HEADERS is not a list of key=value pairs with percent-encoded values, so the signals it configures are not exported", + ], + }, + { + name: "blank per-signal headers leave the generic ones in charge", + env: { + ...ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS: "api-key=shared", + OTEL_EXPORTER_OTLP_LOGS_HEADERS: " ", + }, + traces: { protocol: "http/protobuf", headers: { "api-key": "shared" } }, + logs: { protocol: "http/protobuf", headers: { "api-key": "shared" } }, + warnings: [], + }, + { + name: "generic headers every signal overrides say nothing", + env: { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/v1/traces", + OTEL_EXPORTER_OTLP_TRACES_HEADERS: "api-key=traces", + OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz", + }, + traces: { protocol: "http/protobuf", headers: { "api-key": "traces" } }, + logs: "Unset", + warnings: [], + }, + { + name: "protocol and headers say nothing for a signal no endpoint names", + env: { OTEL_EXPORTER_OTLP_PROTOCOL: "grpc", OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz" }, + traces: "Unset", + logs: "Unset", + warnings: [], + }, + ])("$name", ({ env, traces, logs, warnings }) => + Effect.gen(function* () { + const resolved = yield* load(env); + assert.deepStrictEqual(exportOf(resolved.traces), traces); + assert.deepStrictEqual(exportOf(resolved.logs), logs); + assert.deepStrictEqual(resolved.warnings, warnings); + }), + ); + }); + + describe("resolveSignalEndpoint", () => { + const t3Export = { + protocol: "http/json", + headers: { "x-key": "t3" }, + exportIntervalMs: 5_000, + } as const; + const withLogs = (logs: OtelEnvironment.OtelSignal, disabled = false) => ({ + ...OtelEnvironment.none, + disabled, + logs, + }); + const otelExport = OtelEnvironment.OtelSignal.Export({ + url: "http://otel:4318/v1/logs", + protocol: "http/protobuf", + headers: { "x-key": "otel" }, + }); + it.each([ + { + name: "T3CODE_OTLP_*_URL wins over an OTEL endpoint", + otel: withLogs(otelExport), + t3Url: "http://t3:4318/v1/logs", + expected: { url: "http://t3:4318/v1/logs", export: t3Export }, + }, + { + name: "T3CODE_OTLP_*_URL wins over a signal the OTEL variables turned off", + otel: withLogs(OtelEnvironment.OtelSignal.Off()), + t3Url: "http://t3:4318/v1/logs", + expected: { url: "http://t3:4318/v1/logs", export: t3Export }, + }, + { + name: "an OTEL endpoint brings its headers and protocol over the fallback", + otel: withLogs(otelExport), + t3Url: " ", + expected: { + url: "http://otel:4318/v1/logs", + export: { + protocol: "http/protobuf" as const, + headers: { "x-key": "otel" }, + exportIntervalMs: 5_000, + }, + }, + }, + { + name: "a signal the OTEL variables turned off does not fall through", + otel: withLogs(OtelEnvironment.OtelSignal.Off()), + t3Url: undefined, + expected: undefined, + }, + { + name: "an unset signal takes the first non-blank fallback", + otel: withLogs(OtelEnvironment.OtelSignal.Unset()), + t3Url: undefined, + expected: { url: "http://settings:4318/v1/logs", export: t3Export }, + }, + { + name: "the kill switch wins over everything", + otel: withLogs(otelExport, true), + t3Url: "http://t3:4318/v1/logs", + expected: undefined, + }, + ])("$name", ({ otel, t3Url, expected }) => { + assert.deepStrictEqual( + OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + { url: t3Url, export: t3Export }, + "", + "http://settings:4318/v1/logs", + ), + expected, + ); + }); + }); + describe("layerResourceAttributes", () => { it.effect.each([ { name: "a list that does not decode", raw: "team=%zz", attributes: [] }, diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index 4f0994482e09..05a787dcfede 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -1,608 +1,104 @@ /** - * otelEnvironment: the OpenTelemetry environment variables, read the way the - * specification says to read them. + * otelEnvironment: the OpenTelemetry kill switch and endpoint variables, + * shared by the server and the desktop main process so both agree on what + * turns export off and where it goes. * - * T3 Code has always had its own `T3CODE_OTLP_*` names, which stay the - * explicit answer when they are set. Everything here is the fallback for the - * far more common case: a machine that already exports `OTEL_*` for every - * other service on it and expects one more process to join in without being - * told twice. - * - * Read by every T3 Code process that exports telemetry, so the server and the - * desktop app cannot disagree about what a variable means. That is also why - * `T3CODE_OTEL_SDK_DISABLED` is read here: it is the same setting as - * `OTEL_SDK_DISABLED`, asked of T3 Code's own name first. - * - * Only the variables T3 Code 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. - * - * Everything else the specification requires of an unusable value is a - * warning followed by the default, never a refusal to start and never a - * silently different behavior. + * `T3CODE_OTEL_SDK_DISABLED` is read first, so a machine that sets + * `OTEL_SDK_DISABLED` for everything else can still opt T3 Code back in. * * @module otelEnvironment */ import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; -/** - * The signals T3 Code 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 T3 Code can produce. `grpc` is not one of them. */ -export type OtlpProtocol = "http/json" | "http/protobuf"; - -/** `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`. */ -export type MetricsTemporality = "cumulative" | "delta"; +import { OtlpHeadersFromString, OtlpProtocol, type SignalExport } from "./observability.ts"; -/** - * What metrics are aggregated as when nothing asks for anything, which is the - * specification's default and the one Prometheus and Mimir want. It is named - * here, and applied at the exporter rather than left to the exporter's own - * fallback, so the value that ships is decided in one place instead of tracking - * whatever a dependency happens to default to. - * - * Sending it to a receiver that accepts delta histograms only, which is how - * Datadog's OTLP intake behaves, loses every timer in silence while the - * counters keep arriving. That is a backend fact rather than a bad default, so - * the answer is to set the variable, not to invert this for everyone. - */ -export const DEFAULT_METRICS_TEMPORALITY: MetricsTemporality = "cumulative"; - -/** Everything one signal's exporter needs, or `undefined` if it is off. */ -export interface OtlpSignalSettings { - readonly url: string; - /** - * The wire format for this signal alone. Each signal builds its own - * serializer, so the two are free to differ, and keeping the choice on the - * signal is what stops it from reaching an endpoint these variables did not - * supply. - */ - readonly protocol: OtlpProtocol; - readonly headers: Readonly> | undefined; - readonly exportIntervalMs: number | undefined; - readonly maxBatchSize: number | undefined; - /** Metrics only. Spans and log records have no aggregation to prefer. */ - readonly temporality: MetricsTemporality | undefined; -} +/** The signals T3 Code exports, spelled as the variable names spell them. */ +type OtlpSignalName = "TRACES" | "METRICS" | "LOGS"; /** - * One signal's whole answer from these variables: how to export it, or why it - * is not exported from here. Everything a signal decides lives under it, so a - * caller whose endpoint came from somewhere else drops this one value and - * leaves nothing behind that could reach an export it did not configure. + * What the OTEL variables say about one signal. `Off` is a signal they + * claimed with an endpoint, protocol, or headers that do not read, so it is + * exported nowhere rather than to the bootstrap or Settings collector. */ -export interface OtlpSignal { - readonly settings: OtlpSignalSettings | undefined; - /** - * Why a configured endpoint is not being used, if it is not. Carried rather - * than logged here so the caller can report it once, at startup, where a - * user is looking. - */ - readonly declined: string | undefined; - /** - * Whether these variables named this signal's endpoint and then asked for no - * export from here, by naming another exporter or a transport T3 Code does - * not speak. Carried rather than collapsed into an absent `settings`, because - * an absent `settings` reads as "these variables said nothing about this - * signal", which is what lets a stored endpoint answer instead. - */ - readonly off: boolean; -} +export type OtelSignal = Data.TaggedEnum<{ + Unset: {}; + Off: {}; + Export: { + readonly url: string; + readonly protocol: OtlpProtocol; + readonly headers: Readonly> | undefined; + }; +}>; +export const OtelSignal = Data.taggedEnum(); export interface OtelEnvironment { - /** - * Whether anything is exported at all, by any route. `T3CODE_OTEL_SDK_DISABLED` - * answers it, and `OTEL_SDK_DISABLED` answers it only when T3 Code's own name - * is unset, which is the source order every other setting here follows. So - * `T3CODE_OTEL_SDK_DISABLED=false` is how a machine that exports - * `OTEL_SDK_DISABLED` for everything else keeps T3 Code exporting. - */ + /** Whether OTLP export is off, whatever endpoint is configured. */ readonly disabled: boolean; - /** - * Settings that were named but could not be used, each already phrased for a - * human. The specification requires a warning for a value the implementation - * does not recognize, and these are collected rather than logged here so the - * caller reports them once, at startup, where someone is looking. - */ + /** Messages for the caller to log once at startup. */ readonly warnings: ReadonlyArray; - readonly traces: OtlpSignal; - readonly metrics: OtlpSignal; - readonly logs: OtlpSignal; - /** - * `OTEL_RESOURCE_ATTRIBUTES`, or nothing when it could not be read. - * `service.name` is deliberately absent: each T3 Code process names itself - * and nothing here can rename it, so a fleet-wide `OTEL_SERVICE_NAME` - * cannot quietly merge two processes into one service or file T3 Code - * under some other app's name. An attempt to set it is reported through - * `warnings` rather than ignored in silence. - */ + /** `OTEL_RESOURCE_ATTRIBUTES`, or nothing when it could not be read. */ readonly resourceAttributes: Readonly>; - readonly serviceVersion: string | undefined; + readonly traces: OtelSignal; + readonly metrics: OtelSignal; + readonly logs: OtelSignal; } -/** - * An empty value means the same thing as an unset one. The specification says - * so, and it is how a machine clears a variable it inherited without being - * able to unset it. Surrounding whitespace is dropped for the same reason a - * blank value is: a shell profile that padded a line did not mean the padding - * to become part of an endpoint or a service name. - */ -/** - * A set but blank value is not an answer. Taking one as an answer publishes an - * endpoint nothing can reach and suppresses the source under it that could - * have been used instead. - */ -export const blankAsUnset = (value: string | undefined) => { +/** A set but blank value reads as unset, so the source under it can answer. */ +const blankAsUnset = (value: string | undefined): string | undefined => { const trimmed = value?.trim(); return trimmed === undefined || trimmed === "" ? undefined : trimmed; }; -const optionalString = (name: string) => - Config.String(name).pipe( - Config.option, - Config.map((value) => blankAsUnset(Option.getOrUndefined(value))), - ); - -/** - * The specification defines exactly one true value: the case-insensitive - * string `true`. Everything else is false, including values that read as - * affirmative elsewhere, because implementations are told not to extend the - * list. - */ -const specBoolean = (name: string) => - optionalString(name).pipe( - Effect.map((raw): { readonly value: boolean; readonly warnings: ReadonlyArray } => { - if (raw === undefined) { - return { value: false, warnings: [] }; - } - const value = raw.toLowerCase(); - if (value === "true" || value === "false") { - return { value: value === "true", warnings: [] }; - } - return { - value: false, - warnings: [ - `${name}=${raw} was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way`, - ], - }; - }), - ); - -/** - * A `T3CODE_*` name is ours, so it answers to the affirmatives people actually - * type rather than the single value the specification allows. `undefined` means - * the name did not answer, either because it is unset or because its value was - * unreadable, and the source under it decides instead. A typo therefore costs - * that variable and nothing else, the same as everywhere else here. - */ -const t3Boolean = (name: string) => - optionalString(name).pipe( - Effect.map( - (raw): { readonly value: boolean | undefined; readonly warnings: ReadonlyArray } => { - if (raw === undefined) { - return { value: undefined, warnings: [] }; - } - const value = raw.toLowerCase(); - if (["true", "1", "yes", "on"].includes(value)) { - return { value: true, warnings: [] }; - } - if (["false", "0", "no", "off"].includes(value)) { - return { value: false, warnings: [] }; - } - return { - value: undefined, - warnings: [`${name}=${raw} is not a yes or a no and was ignored`], - }; - }, - ), - ); - -/** - * A number that is not a number is warned about and dropped, which is what the - * specification asks for anywhere a value is unrecognized. Letting the read - * fail instead would take every other variable down with it and turn one typo - * into no telemetry at all. - */ -const readInt = (name: string, warnings: Array) => - optionalString(name).pipe( - Effect.map((raw) => { - if (raw === undefined) { - return undefined; - } - const value = Number(raw); - if (!Number.isSafeInteger(value) || value < 0) { - warnings.push(`${name}=${raw} is not a whole number and was ignored`); - return undefined; - } - return value; - }), - ); - -/** - * A batch of zero is not a smaller batch. The exporter meets a threshold of - * zero on every record, so it stops batching and posts one HTTP request per - * span or log record, which is the shape that takes a collector down rather - * than an unreadable value. Schedule delays keep `readInt`, where zero is a - * real request to drain as fast as the loop allows. - */ -const readPositiveInt = (name: string, warnings: Array) => - readInt(name, warnings).pipe( - Effect.map((value) => { - if (value === 0) { - warnings.push(`${name}=0 is not a batch size and was ignored`); - return undefined; - } - return value; - }), - ); - -/** - * Headers and resource attributes are a W3C Baggage string: comma separated - * pairs, optional whitespace around each one, and percent encoded values. - * - * Splitting on every `=` rather than the first one truncates exactly the - * credentials people put here, since base64 basic auth ends in `=` padding, - * and leaving the encoding in place sends a literal `%20` as part of a bearer - * token. Both fail as an authentication error against the collector, which - * reads like a bad token rather than a parsing bug. - */ -const parseBaggage = (raw: string): Readonly> | undefined => { - const entries: Record = {}; - for (const member of raw.split(",")) { - // Trailing and doubled commas are whitespace, not a member. - if (member.trim() === "") { - continue; - } - const separator = member.indexOf("="); - if (separator === -1) { - return undefined; - } - const key = member.slice(0, separator).trim(); - if (key === "") { - return undefined; - } - const value = member.slice(separator + 1).trim(); - try { - entries[key] = decodeURIComponent(value); - } catch { - return undefined; - } - } - // One bad member discards the list rather than the member. Keeping the rest - // would send a header set nobody asked for: `authorization=token,x-tenant` - // would authenticate and then route to the wrong tenant, which reads as a - // collector problem. A list that produced no pair at all is malformed for - // the same reason, not a request for no headers, and returning `{}` would - // count as a supplied value and shadow the generic variable this signal - // should have fallen back to. - return Object.keys(entries).length === 0 ? undefined : entries; -}; - -interface Parsed { - readonly value: A | undefined; - readonly warnings: ReadonlyArray; +interface Flag { + /** `undefined` when the variable is unset, blank, or unreadable. */ + readonly value: boolean | undefined; + readonly warning?: string; } -/** - * A pair list that fails to decode discards the whole variable, which is what - * the resource specification asks for and the safer answer for headers too: a - * half-parsed credential reaches the collector as an authentication error, - * while nothing plus a warning says where to look. - */ -const optionalRecord = (name: string) => - optionalString(name).pipe( - Effect.map((raw) => { - if (raw === undefined) { - return { value: undefined, warnings: [] }; - } - const parsed = parseBaggage(raw); - return parsed === undefined - ? { - value: undefined, - warnings: [`${name} is not a valid list of key=value pairs and was ignored`], - } - : { value: parsed, warnings: [] }; - }), - ); - -/** - * `OTEL_EXPORTER_OTLP__ENDPOINT` is a full URL and is used as given. - * The generic `OTEL_EXPORTER_OTLP_ENDPOINT` is a base, and the spec has each - * signal append its own path to it. - */ -const signalEndpoint = (signal: OtlpSignalName) => - Effect.gen(function* () { - const specific = yield* optionalString(`OTEL_EXPORTER_OTLP_${signal}_ENDPOINT`); - if (specific !== undefined) { - return specific; - } - const base = yield* optionalString("OTEL_EXPORTER_OTLP_ENDPOINT"); - if (base === undefined) { - return undefined; - } - const trimmed = base.endsWith("/") ? base.slice(0, -1) : base; - return `${trimmed}/v1/${signal.toLowerCase()}`; - }); - -/** - * The exporters the specification names for these signals that T3 Code has no - * implementation of. Naming one is a deliberate "not OTLP", so the signal is - * not exported, and it is worth saying which name did it. - */ -const FOREIGN_EXPORTERS = new Set(["console", "logging", "zipkin", "jaeger", "prometheus"]); - -/** - * `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". - * - * A value that names nothing recognizable is a typo, and a typo is ignored - * here the way every other unreadable value is, which leaves the default in - * place. Reading `otlpp` as "not OTLP" would turn one transposed letter into a - * signal that stops exporting with nothing in the log to connect the two, - * which is the failure this whole reader exists to avoid. - */ -const signalWantsOtlp = (signal: OtlpSignalName, warnings: Array) => - optionalString(`OTEL_${signal}_EXPORTER`).pipe( - Effect.map((raw) => { - if (raw === undefined) { - return true; - } - const name = `OTEL_${signal}_EXPORTER`; - const entries = raw - .split(",") - .map((entry) => entry.trim().toLowerCase()) - .filter((entry) => entry !== ""); - if (entries.includes("otlp")) { - // A list is an ordered preference and OTLP is the only entry honored - // here, so anything standing beside it did nothing. Saying so is what - // keeps the transposed letter in `otlp,otlpp` from reading like a - // second exporter that took. - if (entries.some((entry) => entry !== "otlp")) { - warnings.push( - `${name}=${raw} names otlp, so this signal is exported over OTLP and nothing else in that list is honored`, - ); - } - return true; - } - const recognized = entries.filter( - (entry) => entry === "none" || FOREIGN_EXPORTERS.has(entry), - ); - if (recognized.length === 0) { - warnings.push( - `${name}=${raw} names no exporter T3 Code recognizes and was ignored, so this signal is still exported over OTLP`, - ); - return true; - } - // `none` is the specification's own way to say "export nothing", so it - // needs no explanation. A foreign exporter does: the operator asked for - // an export that happens somewhere else and gets none from here. - if (!entries.includes("none")) { - warnings.push( - `${name}=${raw} asks for an exporter T3 Code does not have, so this signal is not exported`, - ); - } - return false; - }), - ); - -/** - * The specification's own defaults, which apply once this route is the one - * configuring the exporter. A `T3CODE_OTLP_*` setup never reaches here and - * keeps the numbers T3 Code has always used. - */ -const SPEC_DEFAULT_PROTOCOL = "http/protobuf" as const; -const SPEC_DEFAULT_MAX_EXPORT_BATCH_SIZE = 512; +const TrimmedLowercase = Schema.String.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.trim().compose(SchemaTransformation.toLowerCase()), + ), +); /** - * 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. + * Reads a boolean that accepts `truthy` and `falsy`, ignoring case and padding. + * Any other value is ignored with a warning rather than failing startup. */ -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; - } ->; - -/** One signal as these variables read it, before the transport decision. */ -interface ReadSignal extends Parsed { - readonly off: boolean; -} - -const signalSettings = ( - signal: OtlpSignalName, - protocol: OtlpProtocol, - /** - * Metrics only, and read here rather than in `load` so an aggregation shares - * its endpoint's fate. A preference says nothing when these variables named - * nowhere to send metrics or asked for no metrics export, and reporting it - * anyway would claim it took while the endpoint that won still aggregates its - * own way. - */ - temporality: Parsed | undefined, +const flag = ( + name: string, + truthy: ReadonlyArray, + falsy: ReadonlyArray, + invalid: (value: string) => string, ) => - Effect.gen(function* () { - const url = yield* signalEndpoint(signal); - // An exporter list is moot without an endpoint, so it is neither read nor - // reported until one signal has somewhere to go. - if (url === undefined) { - return { value: undefined, warnings: [], off: false } satisfies ReadSignal; - } - const numbers: Array = []; - if (!(yield* signalWantsOtlp(signal, numbers))) { - return { value: undefined, warnings: numbers, off: true } satisfies ReadSignal; - } - 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 = - (yield* readInt(batching.scheduleDelay, numbers)) ?? batching.defaultScheduleDelayMs; - const maxBatchSize = - batching.maxExportBatchSize === undefined - ? undefined - : ((yield* readPositiveInt(batching.maxExportBatchSize, numbers)) ?? - SPEC_DEFAULT_MAX_EXPORT_BATCH_SIZE); - return { - value: { - url, - protocol, - headers, - exportIntervalMs, - maxBatchSize, - temporality: temporality?.value, - }, - warnings: [ - ...specific.warnings, - ...generic.warnings, - ...numbers, - ...(temporality?.warnings ?? []), - ], - off: false, - } satisfies ReadSignal; - }); - -/** What one signal should do about its wire format. */ -interface SignalProtocol { - readonly protocol: OtlpProtocol; - readonly declined: string | undefined; -} - -interface ProtocolDecision { - readonly traces: SignalProtocol; - readonly metrics: SignalProtocol; - readonly logs: SignalProtocol; - readonly warnings: ReadonlyArray; -} - -/** - * Left unset when nothing named a protocol, so a machine that never mentioned - * OpenTelemetry keeps the wire format T3 Code has always used. - * - * `grpc` is the one value that turns export off rather than falling back. It - * is a real protocol T3 Code does not speak, its endpoint has no - * `/v1/traces` path, and it expects a framing nothing here produces, so - * posting to it is worse than exporting nothing. It turns off only the signal - * that named it, since a metric endpoint speaking gRPC says nothing about - * 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 all three are answered separately - * and are free to disagree. - */ -const resolveProtocol = Effect.gen(function* () { - const warnings: Array = []; - const read = function* (name: string) { - const raw = yield* optionalString(name); - if (raw === undefined) { - return undefined; - } - const value = raw.trim().toLowerCase(); - if (value === "http/json" || value === "http/protobuf" || value === "grpc") { - return { value, name } as const; - } - warnings.push(`${name}=${raw} is not a known OTLP protocol and was ignored`); - return undefined; - }; - - 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 - ? { protocol: SPEC_DEFAULT_PROTOCOL, declined: undefined } - : named.value === "grpc" - ? { - protocol: SPEC_DEFAULT_PROTOCOL, - declined: `${named.name}=grpc is not supported; T3 Code exports OTLP over HTTP only, so this signal is not exported`, - } - : { protocol: named.value, declined: undefined }; + Config.schema( + TrimmedLowercase.pipe(Schema.decodeTo(Schema.Literals([...truthy, ...falsy]))), + name, + ).pipe( + Config.map((value): Flag => ({ value: truthy.includes(value) })), + Config.orElse(() => + Config.String(name).pipe( + Config.map((raw): Flag => { + const value = raw.trim(); + return value === "" + ? { value: undefined } + : { value: undefined, warning: invalid(value) }; + }), + ), + ), + Config.withDefault({ value: undefined }), + ); - return { - traces: decide(traces), - metrics: decide(metrics), - logs: decide(logs), - warnings, - } satisfies ProtocolDecision; -}); - -/** - * `lowmemory` is a real preference in the specification that this exporter - * cannot express, because one temporality is applied to every instrument here - * rather than chosen per instrument kind. It resolves to `delta` instead of the - * default, and says so. - * - * `delta` is the honest answer rather than a near-enough one. `lowmemory` asks - * for delta on synchronous counters and histograms and cumulative on the rest, - * and every metric T3 Code records is a monotonic counter or a timer, so the - * kinds the two preferences disagree about are kinds nothing here produces. - * Falling back to the default would have inverted the only part of the request - * that is about the data, and inverted it toward the value that loses it: a - * receiver that accepts delta histograms only, which is how Datadog's OTLP - * intake behaves, drops cumulative histograms without reporting an error, so - * every duration metric would disappear while the counters kept arriving. - * - * A value that is not a preference at all is a different case and stays - * ignored. It carries no intent to honor. - */ -const resolveMetricsTemporality = optionalString( - "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", -).pipe( - Effect.map((raw): Parsed => { - if (raw === undefined) { - return { value: undefined, warnings: [] }; - } - const preference = raw.trim().toLowerCase(); - if (preference === "delta" || preference === "cumulative") { - return { value: preference, warnings: [] }; - } - if (preference === "lowmemory") { - return { - value: "delta", - warnings: [ - "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=lowmemory cannot be expressed per instrument kind here, so delta is used for every metric sent to the endpoint these variables named, which is what lowmemory asks for on the counters and timers T3 Code records", - ], - }; - } - return { - value: undefined, - warnings: [ - `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=${raw} is not a known preference and was ignored, so metrics are exported as ${DEFAULT_METRICS_TEMPORALITY}`, - ], - }; - }), -); +// `Config.Boolean`'s literals, which effect does not export on their own. +const T3CODE_TRUE = ["true", "yes", "on", "1", "y"]; +const T3CODE_FALSE = ["false", "no", "off", "0", "n"]; const RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES"; @@ -630,241 +126,221 @@ const resourceAttributes = Config.Record( Config.withDefault({ value: {} }), ); +interface Setting { + readonly value: A | undefined; + readonly warning?: string; +} + /** - * `OTEL_SERVICE_VERSION` becomes `serviceVersion`, and `service.name` is - * refused, from either variable, because every T3 Code process names itself. + * Reads one variable. Blank reads as unset, and a value `parse` rejects warns + * without echoing it, since these variables carry credentials. */ -const resolveResource = Effect.gen(function* () { - const resource = yield* resourceAttributes; - const { "service.name": attributeName, ...attributes } = resource.value; - const serviceName = yield* optionalString("OTEL_SERVICE_NAME"); - const serviceVersion = yield* optionalString("OTEL_SERVICE_VERSION"); - // Named here only to say it was refused. Dropping it without a word is the - // failure this variable is prone to: the name never changes, the dashboards - // stay empty, and nothing in the log connects the two. - const declinedName = - serviceName !== undefined - ? "OTEL_SERVICE_NAME" - : attributeName === undefined - ? undefined - : `${RESOURCE_ATTRIBUTES}=service.name`; - return { - attributes, - serviceVersion, - warnings: [ - ...(resource.warning === undefined ? [] : [resource.warning]), - ...(declinedName === undefined - ? [] - : [ - `${declinedName} was ignored; every T3 Code process names itself, so use OTEL_RESOURCE_ATTRIBUTES to tell instances apart instead`, - ]), - ], - }; -}); +const readOrWarn = ( + name: string, + parse: (raw: string) => Option.Option, + warning: string, +): Config.Config> => + Config.String(name).pipe( + Config.option, + Config.map((option): Setting => { + const raw = blankAsUnset(Option.getOrUndefined(option)); + if (raw === undefined) { + return { value: undefined }; + } + return Option.match(parse(raw), { + onNone: () => ({ value: undefined, warning }), + onSome: (value) => ({ value }), + }); + }), + ); -const UNREADABLE = "the OpenTelemetry environment could not be read"; +const parseHttpUrl = (raw: string) => + Option.liftThrowable((value: string) => new URL(value))(raw).pipe( + Option.filter((url) => url.protocol === "http:" || url.protocol === "https:"), + ); -/** - * Whichever name switched export off is the one worth naming, because it is - * the one the reader has to go and unset. An ambient `OTEL_SDK_DISABLED` is - * the case where that is not obvious and where the answer is not to unset - * anything, so the message carries the override with it. - */ -const disabledBy = (name: string) => - name === "OTEL_SDK_DISABLED" - ? "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway" - : `${name} is set, so no telemetry is exported, whatever configured it`; +const NOT_EXPORTED = "so the signals it configures are not exported"; -/** - * One signal's whole answer, with the transport decision folded in. - * - * `value` is set only for a signal that resolved an endpoint and asked for - * OTLP, so it is also the test for whether a decline is worth reporting. A - * signal nothing pointed anywhere, one switched off by name, and every signal - * once the SDK is disabled were never going to export, and saying gRPC is why - * would name the wrong cause. - * - * A declined transport is the same kind of answer as a declined exporter, so it - * leaves the signal `off` too: these variables named where this signal goes and - * then ruled out getting it there. - */ -const signalOf = (read: ReadSignal, transport: SignalProtocol): OtlpSignal => - read.value === undefined || transport.declined === undefined - ? { settings: read.value, declined: undefined, off: read.off } - : { settings: undefined, declined: transport.declined, off: true }; +const endpoint = (name: string) => + readOrWarn(name, parseHttpUrl, `${name} is not an http or https URL, ${NOT_EXPORTED}`); + +// The specification reads enum values case-insensitively. +const protocol = (name: string) => + readOrWarn( + name, + (raw) => Schema.decodeUnknownOption(OtlpProtocol)(raw.toLowerCase()), + `${name} is not http/protobuf or http/json, ${NOT_EXPORTED}`, + ); + +const headers = (name: string) => + readOrWarn( + name, + Schema.decodeUnknownOption(OtlpHeadersFromString), + `${name} is not a list of key=value pairs with percent-encoded values, ${NOT_EXPORTED}`, + ); + +interface Settings { + readonly endpoint: Setting; + readonly protocol: Setting; + readonly headers: Setting>>; +} + +const settings = (prefix: string): Config.Config => + Config.all({ + endpoint: endpoint(`${prefix}ENDPOINT`), + protocol: protocol(`${prefix}PROTOCOL`), + headers: headers(`${prefix}HEADERS`), + }); + +/** The signal's own variable claims the signal once set, valid or not. */ +const isClaimed = (setting: Setting) => + setting.value !== undefined || setting.warning !== undefined; + +const claimed = (own: Setting, generic: Setting) => (isClaimed(own) ? own : generic); + +/** Appends the signal's path, keeping the query an intake may take its API key in. */ +const withSignalPath = (signal: OtlpSignalName, base: URL) => { + const url = new URL(base); + const slash = url.pathname.endsWith("/") ? "" : "/"; + url.pathname += `${slash}v1/${signal.toLowerCase()}`; + return url; +}; + +interface ResolvedSignal { + readonly signal: OtelSignal; + /** The settings this signal read, whose warnings are the signal's to report. */ + readonly used: ReadonlyArray>; +} /** - * Read the environment. Never fails: a variable T3 Code cannot honor - * leaves the corresponding setting unset and is reported through the signal's - * `declined`, because an unparseable telemetry knob is not a reason to refuse - * to start. + * A signal whose endpoint, protocol, or headers do not read is not exported + * rather than sent somewhere, in a format, or without the credentials its + * collector expects. */ -export const load: Effect.Effect = Effect.gen(function* () { - const t3 = yield* t3Boolean("T3CODE_OTEL_SDK_DISABLED"); - const spec = yield* specBoolean("OTEL_SDK_DISABLED"); - // One setting, read the way every other setting here is read: T3 Code's own - // name answers it, and the standard name answers it only when ours is unset. - const disabled = t3.value ?? spec.value; - const protocolDecision = yield* resolveProtocol; - const resource = yield* resolveResource; - const temporality = yield* resolveMetricsTemporality; - const traces = disabled - ? { value: undefined, warnings: [], off: false } - : yield* signalSettings("TRACES", protocolDecision.traces.protocol, undefined); - const metrics = disabled - ? { value: undefined, warnings: [], off: false } - : yield* signalSettings("METRICS", protocolDecision.metrics.protocol, temporality); - const logs = disabled - ? { value: undefined, warnings: [], off: false } - : yield* signalSettings("LOGS", protocolDecision.logs.protocol, undefined); +const signal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => { + const ownEndpoint = isClaimed(own.endpoint); + const endpoint = ownEndpoint ? own.endpoint : generic.endpoint; + if (endpoint.value === undefined) { + const signal = endpoint.warning === undefined ? OtelSignal.Unset() : OtelSignal.Off(); + return { signal, used: [endpoint] }; + } + const protocol = claimed(own.protocol, generic.protocol); + const headers = claimed(own.headers, generic.headers); + const used = [endpoint, protocol, headers]; + if (protocol.warning !== undefined || headers.warning !== undefined) { + return { signal: OtelSignal.Off(), used }; + } + const url = ownEndpoint ? endpoint.value : withSignalPath(name, endpoint.value); return { - disabled, - // 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([ - ...t3.warnings, - ...spec.warnings, - ...(disabled - ? [disabledBy(t3.value === true ? "T3CODE_OTEL_SDK_DISABLED" : "OTEL_SDK_DISABLED")] - : []), - ...protocolDecision.warnings, - ...resource.warnings, - ...traces.warnings, - ...metrics.warnings, - ...logs.warnings, - ]), - ], - traces: signalOf(traces, protocolDecision.traces), - metrics: signalOf(metrics, protocolDecision.metrics), - logs: signalOf(logs, protocolDecision.logs), - resourceAttributes: resource.attributes, - serviceVersion: resource.serviceVersion, + signal: OtelSignal.Export({ + url: url.toString(), + protocol: protocol.value ?? "http/protobuf", + headers: headers.value, + }), + used, }; -}).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Could not read the OpenTelemetry environment", cause).pipe( - Effect.as({ - disabled: false, - warnings: [], - traces: { settings: undefined, declined: UNREADABLE, off: false }, - metrics: { settings: undefined, declined: UNREADABLE, off: false }, - logs: { settings: undefined, declined: UNREADABLE, off: false }, - resourceAttributes: {}, - serviceVersion: undefined, - }), - ), +}; + +export const load: Effect.Effect = Config.all({ + t3: flag( + "T3CODE_OTEL_SDK_DISABLED", + T3CODE_TRUE, + T3CODE_FALSE, + (value) => `T3CODE_OTEL_SDK_DISABLED=${value} is not a yes or a no and was ignored`, + ), + // The specification: a boolean it defines is true "only by the + // case-insensitive string `true`", implementations "MUST NOT" accept other + // values as true, and should warn about unrecognized ones. + spec: flag( + "OTEL_SDK_DISABLED", + ["true"], + ["false"], + (value) => + `OTEL_SDK_DISABLED=${value} was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way`, ), + resource: resourceAttributes, + generic: settings("OTEL_EXPORTER_OTLP_"), + traces: settings("OTEL_EXPORTER_OTLP_TRACES_"), + metrics: settings("OTEL_EXPORTER_OTLP_METRICS_"), + logs: settings("OTEL_EXPORTER_OTLP_LOGS_"), +}).pipe( + Effect.map(({ t3, spec, resource, generic, ...own }) => { + const disabled = t3.value ?? spec.value ?? false; + // The kill switch wins outright, so the signals say nothing once it is set. + const signals = disabled + ? undefined + : { + traces: signal("TRACES", own.traces, generic), + metrics: signal("METRICS", own.metrics, generic), + logs: signal("LOGS", own.logs, generic), + }; + // A generic variable read by several signals warns once. + const used = new Set( + signals === undefined ? [] : Object.values(signals).flatMap((resolved) => resolved.used), + ); + const warnings = [ + t3.warning, + spec.warning, + resource.warning, + ...Array.from(used, (setting) => setting.warning), + ].filter((warning) => warning !== undefined); + if (disabled) { + warnings.push( + t3.value + ? "T3CODE_OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it" + : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway", + ); + } + return { + disabled, + warnings, + resourceAttributes: resource.value, + traces: signals?.traces.signal ?? OtelSignal.Unset(), + metrics: signals?.metrics.signal ?? OtelSignal.Unset(), + logs: signals?.logs.signal ?? OtelSignal.Unset(), + }; + }), + // Every read above falls back instead of failing, so this cannot happen. + Effect.orDie, ); -/** A signal these variables said nothing usable about. */ -const noSignal: OtlpSignal = { settings: undefined, declined: undefined, off: false }; +export type SignalName = "traces" | "metrics" | "logs"; -/** How one signal is actually exported, after its owner has been decided. */ -export interface SignalExport { - readonly protocol: OtlpProtocol; - readonly headers: Readonly> | undefined; - readonly exportIntervalMs: number; - readonly maxBatchSize: number | undefined; - readonly temporality: MetricsTemporality; +export interface SignalEndpoint { + readonly url: string; + readonly export: SignalExport; } /** - * What T3 Code exports with when no source configured a signal: its own wire - * format, its own cadence, no headers, and the specification's aggregation. - * Named once so the server, the Electron main process, and the fixtures that - * stand in for them do not each carry a copy of the same literals and drift - * apart from the shipped behavior. + * Where one signal exports and how. `T3CODE_OTLP_*_URL` wins outright with + * T3 Code's own export, then an OTEL endpoint with its own headers and + * protocol, since `T3CODE_OTLP_HEADERS` was written for a different + * collector, then the first of `fallbackUrls` with T3 Code's own export. */ -export const DEFAULT_SIGNAL_EXPORT: SignalExport = { - protocol: "http/json", - headers: undefined, - exportIntervalMs: 10_000, - maxBatchSize: undefined, - temporality: DEFAULT_METRICS_TEMPORALITY, -}; - -/** - * Applies the whole-signal rule to the knobs, not only to the URL: the source - * that named a signal's endpoint configures everything about that signal, and - * the other source is not consulted for the parts it left unset. - * - * Written here rather than as `settings?.headers ?? t3Headers` at each call - * site because optional chaining collapses the two cases this has to keep - * apart. Settings that do not exist mean the standard variables named nothing - * and T3 Code's own answer applies. Settings that exist and say nothing about - * one knob mean the standard variables own this signal and are silent about - * that knob, which is an answer of its own. Borrowing T3 Code's value there - * sends a `T3CODE_OTLP_HEADERS` credential to a collector only - * `OTEL_EXPORTER_OTLP_ENDPOINT` named, and lets `T3CODE_OTLP_EXPORT_INTERVAL_MS` - * set the cadence of an export it did not configure. - */ -export const resolveSignalExport = (input: { - readonly settings: OtlpSignalSettings | undefined; - readonly t3Protocol: OtlpProtocol; - readonly t3Headers: Readonly> | undefined; - readonly t3ExportIntervalMs: number; -}): SignalExport => - input.settings === undefined - ? { - protocol: input.t3Protocol, - headers: input.t3Headers, - exportIntervalMs: input.t3ExportIntervalMs, - maxBatchSize: undefined, - temporality: DEFAULT_METRICS_TEMPORALITY, - } - : { - protocol: input.settings.protocol, - headers: input.settings.headers, - exportIntervalMs: input.settings.exportIntervalMs ?? input.t3ExportIntervalMs, - maxBatchSize: input.settings.maxBatchSize, - temporality: input.settings.temporality ?? DEFAULT_METRICS_TEMPORALITY, - }; - -/** - * Where one signal's endpoint comes from, and therefore which source - * configures the rest of it. Sources are asked in the order every setting - * here follows: T3 Code's own name, then the standard `OTEL_*` names, then - * whatever was persisted, meaning the desktop bootstrap envelope or Settings. - * An exported variable outranks a stored one, and T3 Code's own spelling of a - * variable outranks the standard spelling of it. - * - * Whichever source wins takes the whole signal and not the URL alone, so the - * signal returned here is `noSignal` unless `OTEL_*` is what won. That is what - * stops an ambient `OTEL_EXPORTER_OTLP_ENDPOINT` from changing the wire - * format, headers, batching, or aggregation of an export it never pointed - * anywhere, and stops startup reporting a signal as declined while it is - * exporting. When nothing names an endpoint the signal is returned as it was - * read, because a declined transport is still worth saying when there is no - * export to confuse it with. - * - * A signal the standard variables switched off is not the same as one they said - * nothing about, so a persisted endpoint does not get to re-enable it. The - * exported variable is the more recent answer, and answering the opposite from - * a stored one would export a signal an operator just turned off. - * - * Read by every process that exports, so the server and the desktop app - * cannot resolve the same machine's variables differently. - */ -export const resolveSignalSource = (input: { - readonly t3Url: string | undefined; - readonly signal: OtlpSignal; - readonly persistedUrl: string | undefined; -}): { readonly url: string | undefined; readonly signal: OtlpSignal } => { - const t3Url = blankAsUnset(input.t3Url); - if (t3Url !== undefined) { - return { url: t3Url, signal: noSignal }; - } - if (input.signal.settings !== undefined) { - return { url: input.signal.settings.url, signal: input.signal }; +export const resolveSignalEndpoint = ( + otel: OtelEnvironment, + signal: SignalName, + t3: { readonly url: string | undefined; readonly export: SignalExport }, + ...fallbackUrls: ReadonlyArray +): SignalEndpoint | undefined => { + if (otel.disabled) { + return undefined; } - if (input.signal.off) { - return { url: undefined, signal: input.signal }; + const t3Url = blankAsUnset(t3.url); + if (t3Url !== undefined) { + return { url: t3Url, export: t3.export }; } - const persistedUrl = blankAsUnset(input.persistedUrl); - return persistedUrl === undefined - ? { url: undefined, signal: input.signal } - : { url: persistedUrl, signal: noSignal }; + return OtelSignal.$match(otel[signal], { + Export: ({ url, protocol, headers }): SignalEndpoint => ({ + url, + export: { protocol, headers, exportIntervalMs: t3.export.exportIntervalMs }, + }), + Off: () => undefined, + Unset: () => { + const url = fallbackUrls.map(blankAsUnset).find((candidate) => candidate !== undefined); + return url === undefined ? undefined : { url, export: t3.export }; + }, + }); }; /** @@ -890,9 +366,8 @@ export const layerResourceAttributes = (attributes: Readonly