diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index e5000f527cd2..d6cc42a41b29 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -365,9 +365,14 @@ const resolveOtlpExport = Effect.gen(function* () { return resolveDesktopOtlpExport({ otel: environment.otelEnvironment, named: { - traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl, - metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl, - logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl, + 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), diff --git a/apps/desktop/src/app/DesktopOtlpExport.test.ts b/apps/desktop/src/app/DesktopOtlpExport.test.ts index f495354e310f..34bbfea059a6 100644 --- a/apps/desktop/src/app/DesktopOtlpExport.test.ts +++ b/apps/desktop/src/app/DesktopOtlpExport.test.ts @@ -6,11 +6,11 @@ import * as Layer from "effect/Layer"; import { DEFAULT_DESKTOP_EXPORT_INTERVAL_MS, - type DesktopNamedOtlpEndpoints, + type DesktopOtlpEndpoints, resolveDesktopOtlpExport, } from "./DesktopOtlpExport.ts"; -const noNamedEndpoints: DesktopNamedOtlpEndpoints = { +const noEndpoints: DesktopOtlpEndpoints = { traces: undefined, metrics: undefined, logs: undefined, @@ -19,7 +19,8 @@ const noNamedEndpoints: DesktopNamedOtlpEndpoints = { const resolve = ( env: Record, overrides: { - readonly named?: Partial; + readonly named?: Partial; + readonly persisted?: Partial; readonly namedExportIntervalMs?: number; readonly namedHeaders?: Readonly>; readonly namedProtocol?: "http/json" | "http/protobuf"; @@ -30,7 +31,8 @@ const resolve = ( Effect.map((otel) => resolveDesktopOtlpExport({ otel, - named: { ...noNamedEndpoints, ...overrides.named }, + named: { ...noEndpoints, ...overrides.named }, + persisted: { ...noEndpoints, ...overrides.persisted }, namedExportIntervalMs: overrides.namedExportIntervalMs, namedHeaders: overrides.namedHeaders, namedProtocol: overrides.namedProtocol, @@ -63,6 +65,45 @@ describe("resolveDesktopOtlpExport", () => { }), ); + 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( @@ -167,14 +208,18 @@ describe("resolveDesktopOtlpExport", () => { }), ); - it.effect("honors one T3 Code interval across every signal", () => + it.effect("honors one T3 Code interval across every signal it named", () => Effect.gen(function* () { const resolved = yield* resolve( + {}, { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_METRIC_EXPORT_INTERVAL: "30000", + 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, }, - { namedExportIntervalMs: 2500 }, ); assert.strictEqual(resolved.traces.exportIntervalMs, 2500); assert.strictEqual(resolved.metrics.exportIntervalMs, 2500); @@ -182,6 +227,42 @@ describe("resolveDesktopOtlpExport", () => { }), ); + 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({ diff --git a/apps/desktop/src/app/DesktopOtlpExport.ts b/apps/desktop/src/app/DesktopOtlpExport.ts index 5f6b8d1214b3..b76d2f99c0f5 100644 --- a/apps/desktop/src/app/DesktopOtlpExport.ts +++ b/apps/desktop/src/app/DesktopOtlpExport.ts @@ -29,7 +29,7 @@ export interface DesktopOtlpSignal { readonly protocol: OtelEnvironment.OtlpProtocol; readonly headers: Readonly> | undefined; readonly maxBatchSize: number | undefined; - readonly temporality: OtelEnvironment.MetricsTemporality | undefined; + readonly temporality: OtelEnvironment.MetricsTemporality; } export interface DesktopOtlpResource { @@ -47,8 +47,8 @@ export interface DesktopOtlpExport { readonly warnings: ReadonlyArray; } -/** An endpoint named outside the OpenTelemetry variables, per signal. */ -export interface DesktopNamedOtlpEndpoints { +/** One endpoint per signal, from a single source. */ +export interface DesktopOtlpEndpoints { readonly traces: string | undefined; readonly metrics: string | undefined; readonly logs: string | undefined; @@ -56,7 +56,10 @@ export interface DesktopNamedOtlpEndpoints { export interface DesktopOtlpExportInput { readonly otel: OtelEnvironment.OtelEnvironment; - readonly named: DesktopNamedOtlpEndpoints; + /** `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. */ @@ -79,41 +82,35 @@ const offSignal: DesktopOtlpSignal = { protocol: DEFAULT_DESKTOP_PROTOCOL, headers: undefined, maxBatchSize: undefined, - temporality: undefined, + temporality: OtelEnvironment.DEFAULT_METRICS_TEMPORALITY, }; /** - * A signal whose endpoint came from somewhere else is not the OpenTelemetry - * variables' to configure. Dropping the whole signal, rather than the endpoint - * alone, is what stops an ambient `OTEL_EXPORTER_OTLP_ENDPOINT` from changing - * the wire format, headers, or batching of an export a `T3CODE_OTLP_*` name or - * Settings already answered. + * 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 = ( - named: string | undefined, - signal: OtelEnvironment.OtlpSignal, + resolved: { readonly url: string | undefined; readonly signal: OtelEnvironment.OtlpSignal }, input: DesktopOtlpExportInput, ): DesktopOtlpSignal => { - const settings = named === undefined ? signal.settings : undefined; - const url = named ?? settings?.url; - if (url === undefined) { + if (resolved.url === undefined) { return offSignal; } return { - url, - exportIntervalMs: - input.namedExportIntervalMs ?? - settings?.exportIntervalMs ?? - DEFAULT_DESKTOP_EXPORT_INTERVAL_MS, - protocol: settings?.protocol ?? input.namedProtocol ?? DEFAULT_DESKTOP_PROTOCOL, - headers: settings?.headers ?? input.namedHeaders, - maxBatchSize: settings?.maxBatchSize, - temporality: settings?.temporality, + 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, named } = input; + const { otel } = input; const resource: DesktopOtlpResource = { serviceName: input.serviceName, serviceVersion: otel.resource.serviceVersion, @@ -130,23 +127,33 @@ export const resolveDesktopOtlpExport = (input: DesktopOtlpExportInput): Desktop }; } - const signals = { - traces: named.traces === undefined ? otel.traces : OtelEnvironment.noSignal, - metrics: named.metrics === undefined ? otel.metrics : OtelEnvironment.noSignal, - logs: named.logs === undefined ? otel.logs : OtelEnvironment.noSignal, - }; + 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(named.traces, signals.traces, input), - metrics: resolveSignal(named.metrics, signals.metrics, input), - logs: resolveSignal(named.logs, signals.logs, input), + traces: resolveSignal(traces, input), + metrics: resolveSignal(metrics, input), + logs: resolveSignal(logs, input), resource, warnings: [ ...new Set([ ...otel.warnings, - ...[signals.traces.declined, signals.metrics.declined, signals.logs.declined].filter( + ...[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 41eeedc668b6..f78a34ac9ba6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -946,6 +946,116 @@ 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 preserves existing WSLENV entries when forwarding backend secrets", () => 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 9e809667ac42..bc926e399ab9 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -91,13 +91,60 @@ 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 is handled -// separately via a `--dev-url` CLI flag because WSLENV translation of -// URL-shaped values (colons / slashes) is unreliable. +// separately via a `--dev-url` CLI flag. +// 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 = [ + "T3CODE_OTEL_SDK_DISABLED", + "T3CODE_OTLP_TRACES_URL", + "T3CODE_OTLP_METRICS_URL", + "T3CODE_OTLP_LOGS_URL", + "T3CODE_OTLP_HEADERS", + "T3CODE_OTLP_PROTOCOL", + "T3CODE_OTLP_EXPORT_INTERVAL_MS", + "T3CODE_OTLP_SERVICE_NAME", + "OTEL_SDK_DISABLED", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + "OTEL_EXPORTER_OTLP_LOGS_HEADERS", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "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", - "T3CODE_OTLP_HEADERS", - "T3CODE_OTLP_PROTOCOL", + ...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 fc6f1aa33075..3095a3a20b19 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -102,12 +102,10 @@ const makeCliTestServerConfig = (baseDir: string) => otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, - otlpMetricsExportIntervalMs: 10_000, - otlpLogsExportIntervalMs: 10_000, + otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", 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 bda5fbdbd2d9..02958e1c9e69 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -53,12 +53,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, - otlpMetricsExportIntervalMs: 10_000, - otlpLogsExportIntervalMs: 10_000, + otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", otelEnvironment: OtelEnvironment.none, devAllowedOrigins: [], } as const; @@ -711,7 +709,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otelEnvironment.metrics.settings?.url).toBe( "https://collector.example.com/v1/metrics", ); - expect(resolved.otlpExportIntervalMs).toBe(10_000); + expect(resolved.otlpTracesExport.exportIntervalMs).toBe(10_000); }), ); @@ -738,8 +736,38 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_OTLP_METRICS_URL: "http://localhost:4318/v1/metrics", }); - expect(resolved.otlpExportIntervalMs).toBe(5_000); - expect(resolved.otlpMetricsExportIntervalMs).toBe(10_000); + 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"); }), ); @@ -755,8 +783,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }); expect(resolved.otelEnvironment.logs.settings).toBeUndefined(); - expect(resolved.otlpExportIntervalMs).toBe(7_000); - expect(resolved.otlpLogsExportIntervalMs).toBe(10_000); + expect(resolved.otlpTracesExport.exportIntervalMs).toBe(7_000); + expect(resolved.otlpLogsExport.exportIntervalMs).toBe(10_000); }), ); @@ -875,6 +903,236 @@ 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; @@ -974,7 +1232,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ), ); - expect(resolved.otlpHeaders).toEqual({ + expect(resolved.otlpTracesExport.headers).toEqual({ authorization: "Basic abc==", "x-tenant": "t3", }); @@ -1018,7 +1276,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ), ); - expect(resolved.otlpHeaders).toEqual({ + expect(resolved.otlpTracesExport.headers).toEqual({ authorization: "Bearer abc==", "x-tenant": "t3", }); @@ -1058,7 +1316,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ), ); - expect(resolved.otlpProtocol).toBe("http/protobuf"); + expect(resolved.otlpTracesExport.protocol).toBe("http/protobuf"); }), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 84ac0a8c61da..146ec2c1aeff 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -233,10 +233,8 @@ const resolveOptionPrecedence = ( ): Option.Option => Option.firstSomeOf(values); /** - * Reads a source that names an OTLP destination, treating a blank one as - * nobody having named it. An empty variable is set in the environment but is - * not an answer, and taking it as one both publishes an endpoint that cannot - * be reached and suppresses the ambient variable that could have been. + * A set but blank `T3CODE_OTLP_SERVICE_NAME` is not a name. Taking one as an + * answer would file every span under the empty string. */ const named = (value: string | undefined) => { const trimmed = value?.trim(); @@ -399,30 +397,59 @@ export const resolveServerConfig = ( ); const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); - // A signal whose endpoint came from somewhere else is not this route's to - // configure. Dropping the whole signal, rather than the endpoint alone, - // is what stops an ambient OTEL_EXPORTER_OTLP_ENDPOINT from changing the - // wire format, headers, batching, or aggregation of an export that a - // T3CODE_OTLP_* name or Settings already answered, and stops startup from - // reporting that signal as declined while it is exporting. - const namedTracesUrl = named( - env.otlpTracesUrl ?? bootstrap?.otlpTracesUrl ?? persistedObservabilitySettings.otlpTracesUrl, - ); - const namedMetricsUrl = named( - env.otlpMetricsUrl ?? - bootstrap?.otlpMetricsUrl ?? + // 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 namedLogsUrl = named( - env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl, - ); + ), + }); + const logs = OtelEnvironment.resolveSignalSource({ + t3Url: env.otlpLogsUrl, + signal: otel.logs, + persistedUrl: persistedUrl( + bootstrap?.otlpLogsUrl, + persistedObservabilitySettings.otlpLogsUrl, + ), + }); const otelEnvironment = { ...otel, - traces: namedTracesUrl === undefined ? otel.traces : OtelEnvironment.noSignal, - metrics: namedMetricsUrl === undefined ? otel.metrics : OtelEnvironment.noSignal, - logs: namedLogsUrl === undefined ? otel.logs : OtelEnvironment.noSignal, + traces: traces.signal, + metrics: metrics.signal, + logs: logs.signal, } satisfies OtelEnvironment.OtelEnvironment; + // 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, + }); + const config: ServerConfig.ServerConfig["Service"] = { logLevel, traceMinLevel: env.traceMinLevel, @@ -430,30 +457,14 @@ export const resolveServerConfig = ( traceBatchWindowMs: env.traceBatchWindowMs, traceMaxBytes: env.traceMaxBytes, traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: otelEnvironment.disabled - ? undefined - : (namedTracesUrl ?? otelEnvironment.traces.settings?.url), - otlpMetricsUrl: otelEnvironment.disabled - ? undefined - : (namedMetricsUrl ?? otelEnvironment.metrics.settings?.url), - otlpLogsUrl: otelEnvironment.disabled - ? undefined - : (namedLogsUrl ?? otelEnvironment.logs.settings?.url), - // T3 Code has one interval variable and it deliberately covers every - // signal. The per-signal part is the fallback under it: the environment - // names a span delay, a metric interval, and a log record delay - // separately, so a signal that took its endpoint elsewhere must not - // inherit another one's. - otlpExportIntervalMs: - env.otlpExportIntervalMs ?? otelEnvironment.traces.settings?.exportIntervalMs ?? 10_000, - otlpMetricsExportIntervalMs: - env.otlpExportIntervalMs ?? otelEnvironment.metrics.settings?.exportIntervalMs ?? 10_000, - otlpLogsExportIntervalMs: - env.otlpExportIntervalMs ?? otelEnvironment.logs.settings?.exportIntervalMs ?? 10_000, + 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), otlpServiceName: named(env.otlpServiceName) ?? "t3-server", otelEnvironment, - otlpHeaders: env.otlpHeaders, - otlpProtocol: env.otlpProtocol, mode, port, cwd, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 2bf76eb2cace..f64150f7425f 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -322,12 +322,10 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, - otlpMetricsExportIntervalMs: 10_000, - otlpLogsExportIntervalMs: 10_000, + otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", otelEnvironment: OtelEnvironment.none, mode: "web", port: state.port, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e88a00c12e49..cbec55e6aa80 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -18,7 +18,6 @@ import type * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; -import { OtlpProtocol } from "@t3tools/shared/observability"; export const DEFAULT_PORT = 3773; @@ -74,14 +73,16 @@ export class ServerConfig extends Context.Service< readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; readonly otlpLogsUrl: string | undefined; - readonly otlpExportIntervalMs: number; - readonly otlpMetricsExportIntervalMs: number; - readonly otlpLogsExportIntervalMs: number; + /** + * 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; readonly otlpServiceName: string; - /** `T3CODE_OTLP_HEADERS`, which deliberately covers every signal. */ - readonly otlpHeaders: Readonly> | undefined; - /** `T3CODE_OTLP_PROTOCOL`, the wire format a named endpoint is sent. */ - readonly otlpProtocol: OtlpProtocol; /** * What the standard `OTEL_*` variables asked for. The endpoints above are * already resolved from it; this carries the rest, which T3 Code has no @@ -229,12 +230,10 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, - otlpMetricsExportIntervalMs: 10_000, - otlpLogsExportIntervalMs: 10_000, + otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", otelEnvironment: OtelEnvironment.none, cwd, baseDir, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 34a210125861..71ac8a98cdbb 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -55,12 +55,10 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, - otlpMetricsExportIntervalMs: 10_000, - otlpLogsExportIntervalMs: 10_000, + otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", otelEnvironment: OtelEnvironment.none, cwd: process.cwd(), baseDir, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 0fe0218873be..7533b8c1db19 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -322,7 +322,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig.ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; - const otlpHeaders = config.otelEnvironment.traces.settings?.headers ?? config.otlpHeaders; + const otlpHeaders = config.otlpTracesExport.headers; const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector; const httpClient = yield* HttpClient.HttpClient; const serialization = yield* OtlpSerialization.OtlpSerialization; diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index e8c11eb0671a..985dcc0ea5a8 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -1,4 +1,5 @@ import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { makeLocalFileTracer, makeTraceSink, @@ -39,12 +40,9 @@ export const ObservabilityLive = Layer.unwrap( } // Each signal builds its own serializer, so the wire format travels with - // the settings of the endpoint that asked for it. A signal these variables - // did not supply keeps what T3CODE_OTLP_PROTOCOL asked for. - const serializationFor = (settings: typeof otel.traces.settings) => - otlpSerializationLayer(settings?.protocol ?? config.otlpProtocol); - const headersFor = (settings: typeof otel.traces.settings) => - settings?.headers ?? config.otlpHeaders; + // the endpoint that asked for it rather than with this process. + const serializationFor = (signal: OtelEnvironment.SignalExport) => + otlpSerializationLayer(signal.protocol); const otlpResource = ServerConfig.otlpResource(config); @@ -75,12 +73,12 @@ export const ObservabilityLive = Layer.unwrap( ? undefined : yield* OtlpTracer.make({ url: config.otlpTracesUrl, - exportInterval: `${config.otlpExportIntervalMs} millis`, + exportInterval: `${config.otlpTracesExport.exportIntervalMs} millis`, resource: otlpResource, - headers: headersFor(otel.traces.settings), - ...(otel.traces.settings?.maxBatchSize === undefined + headers: config.otlpTracesExport.headers, + ...(config.otlpTracesExport.maxBatchSize === undefined ? {} - : { maxBatchSize: otel.traces.settings.maxBatchSize }), + : { maxBatchSize: config.otlpTracesExport.maxBatchSize }), }); const tracer = yield* makeLocalFileTracer({ @@ -102,7 +100,7 @@ export const ObservabilityLive = Layer.unwrap( // 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(otel.traces.settings)), + Layer.provideMerge(serializationFor(config.otlpTracesExport)), ); const metricsLayer = @@ -110,13 +108,11 @@ export const ObservabilityLive = Layer.unwrap( ? Layer.empty : OtlpMetrics.layer({ url: config.otlpMetricsUrl, - exportInterval: `${config.otlpMetricsExportIntervalMs} millis`, + exportInterval: `${config.otlpMetricsExport.exportIntervalMs} millis`, resource: otlpResource, - headers: headersFor(otel.metrics.settings), - ...(otel.metrics.settings?.temporality === undefined - ? {} - : { temporality: otel.metrics.settings.temporality }), - }).pipe(Layer.provide(serializationFor(otel.metrics.settings))); + headers: config.otlpMetricsExport.headers, + temporality: config.otlpMetricsExport.temporality, + }).pipe(Layer.provide(serializationFor(config.otlpMetricsExport))); return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 9350c2354fba..fca92a60e47f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -583,12 +583,10 @@ const buildAppUnderTest = (options?: { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, - otlpMetricsExportIntervalMs: 10_000, - otlpLogsExportIntervalMs: 10_000, + otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", otelEnvironment: OtelEnvironment.none, mode: "desktop", port: 0, @@ -1116,7 +1114,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.browserTraceCollector, }), ), - Layer.provide(otlpSerializationLayer(config.otlpProtocol)), + Layer.provide(otlpSerializationLayer(config.otlpTracesExport.protocol)), Layer.provide( Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({ publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), @@ -5377,7 +5375,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ config: { otlpTracesUrl: collector.url, - otlpProtocol: "http/protobuf", + otlpTracesExport: { + ...OtelEnvironment.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 dd99f8cd468e..88710744179c 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -52,12 +52,10 @@ const configLayer = (overrides: Partial) = otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - otlpExportIntervalMs: 10_000, - otlpMetricsExportIntervalMs: 10_000, - otlpLogsExportIntervalMs: 10_000, + otlpTracesExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: OtelEnvironment.DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", - otlpHeaders: undefined, - otlpProtocol: "http/json", otelEnvironment: OtelEnvironment.none, cwd: baseDir, baseDir, @@ -161,21 +159,17 @@ 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", - otelEnvironment: { - ...OtelEnvironment.none, - logs: { - settings: { - url: "https://collector.example.com/v1/logs", - protocol: "http/protobuf", - headers: { "x-scope": "logs" }, - exportIntervalMs: 1_000, - maxBatchSize: 512, - temporality: undefined, - }, - declined: undefined, - }, + otlpLogsExport: { + ...OtelEnvironment.DEFAULT_SIGNAL_EXPORT, + protocol: "http/protobuf", + headers: { "x-scope": "logs" }, + exportIntervalMs: 1_000, + maxBatchSize: 512, }, }); @@ -185,20 +179,6 @@ describe("ServerLoggerLive", () => { }), ); - it.effect("falls back to the headers and wire format T3 Code's own names asked for", () => - Effect.gen(function* () { - const requests = yield* logThrough({ - otlpLogsUrl: "https://collector.example.com/v1/logs", - otlpHeaders: { "x-scope": "named" }, - otlpProtocol: "http/protobuf", - }); - - assert.lengthOf(requests, 1); - assert.strictEqual(requests[0]?.headers["x-scope"], "named"); - assert.strictEqual(requests[0]?.headers["content-type"], "application/x-protobuf"); - }), - ); - it.effect("attaches log messages to the active span when no logs endpoint is configured", () => Effect.gen(function* () { const { requests, spans } = yield* logInSpanThrough({}); diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index 828737c0b3cd..3bbf86d5202e 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -12,19 +12,16 @@ export const ServerLoggerLive = Effect.gen(function* () { const config = yield* ServerConfig; const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel); - const settings = config.otelEnvironment.logs.settings; - // A log endpoint these variables did not supply keeps the headers and wire - // format T3 Code's own names asked for. - const headers = settings?.headers ?? config.otlpHeaders; + const logs = config.otlpLogsExport; const otlpLogger = config.otlpLogsUrl === undefined ? undefined : OtlpLogger.make({ url: config.otlpLogsUrl, - exportInterval: `${config.otlpLogsExportIntervalMs} millis`, + exportInterval: `${logs.exportIntervalMs} millis`, resource: otlpResource(config), - ...(headers === undefined ? {} : { headers }), - ...(settings?.maxBatchSize === undefined ? {} : { maxBatchSize: settings.maxBatchSize }), + ...(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 @@ -46,7 +43,7 @@ export const ServerLoggerLive = Effect.gen(function* () { { mergeWithExisting: false }, ).pipe( Layer.provide(OtlpExporter.layerFlusher), - Layer.provide(otlpSerializationLayer(settings?.protocol ?? config.otlpProtocol)), + Layer.provide(otlpSerializationLayer(logs.protocol)), ); return Layer.mergeAll(loggerLayer, minimumLogLevelLayer); diff --git a/docs/fork/0018-the-standard-otel-variables-are-honored.md b/docs/fork/0018-the-standard-otel-variables-are-honored.md index a0b7aa26a4b2..bf826e8fe7b6 100644 --- a/docs/fork/0018-the-standard-otel-variables-are-honored.md +++ b/docs/fork/0018-the-standard-otel-variables-are-honored.md @@ -29,9 +29,11 @@ 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, the desktop bootstrap - envelope, and Settings all still win over the environment, and a setup that - never mentioned OpenTelemetry keeps the wire format it always used. +- 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 @@ -74,6 +76,9 @@ 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 a handful of fallbacks at the end of -existing precedence chains. A sync that rewrites those chains must keep the -environment as their last entry. +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/operations/observability.md b/docs/operations/observability.md index 0e09de740486..fd20ea4f0a3b 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -250,9 +250,15 @@ main process was given. For each signal, the first source that names its endpoint wins: 1. `T3CODE_OTLP_*` -2. the desktop bootstrap envelope -3. Settings, under `observability` -4. `OTEL_*` +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 @@ -265,6 +271,15 @@ 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 @@ -286,7 +301,7 @@ telemetry. | `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` or `delta` | +| `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`. @@ -294,7 +309,7 @@ specification, and follows `T3CODE_OTLP_PROTOCOL` otherwise, which defaults to ` `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 that signal had no other endpoint to go to. +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 @@ -308,6 +323,14 @@ Not everything in the specification is implemented. These are the ones worth kno 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 @@ -317,8 +340,21 @@ Not everything in the specification is implemented. These are the ones worth kno 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. -- **`lowmemory` temporality is not available.** `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` - accepts `cumulative` and `delta`. `lowmemory` logs a warning and falls back to `cumulative`. +- **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. @@ -336,12 +372,17 @@ variables, propagator variables, and the attribute and span limit variables. 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, an unavailable temporality, a timeout or - batch size that is not a whole number, 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. -- **Export off.** Only `OTEL_EXPORTER_OTLP_PROTOCOL=grpc` does this, because it names a transport - T3 Code does not speak rather than a value it failed to parse. +- **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, @@ -351,14 +392,19 @@ anything else, including `yes` and `1`, leaves it on. `T3CODE_OTEL_SDK_DISABLED` 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 that fails to decode is discarded -whole rather than partly. A half-parsed credential reaches the collector as the same authentication -error a wrong one would, which reads like a bad token instead of a bad variable. +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, the desktop bootstrap envelope, or Settings winning the URL takes the whole signal with it, so -an ambient `OTEL_EXPORTER_OTLP_ENDPOINT` cannot reach in and change the wire format, headers, or -batching of an export it did not point anywhere. Traces, metrics, and logs are answered separately +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. diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts index 166801f6ead4..47db737990e5 100644 --- a/packages/shared/src/otelEnvironment.test.ts +++ b/packages/shared/src/otelEnvironment.test.ts @@ -93,6 +93,7 @@ describe("OtelEnvironment", () => { }), ); assert.isDefined(resolved.traces.settings); + assert.isTrue(resolved.warnings.some((warning) => warning.includes("console, otlp"))); }), ); @@ -445,17 +446,63 @@ describe("OtelEnvironment", () => { }), ); - it.effect("warns about a temporality this exporter cannot produce", () => + 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.isTrue(resolved.warnings.some((warning) => warning.includes("lowmemory"))); + assert.strictEqual(resolved.metrics.settings?.temporality, undefined); + assert.deepStrictEqual(resolved.warnings, []); }), ); @@ -732,4 +779,186 @@ describe("OtelEnvironment", () => { 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" }); + }), + ); }); diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index 0d623b154089..43d25a8a370a 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -40,6 +40,20 @@ export type OtlpProtocol = "http/json" | "http/protobuf"; /** `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`. */ export type MetricsTemporality = "cumulative" | "delta"; +/** + * 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; @@ -71,6 +85,14 @@ export interface OtlpSignal { * 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; } /** @@ -115,13 +137,20 @@ export interface OtelEnvironment { * 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) => { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed === "" ? undefined : trimmed; +}; + const optionalString = (name: string) => Config.String(name).pipe( Config.option, - Config.map((value) => { - const raw = Option.getOrUndefined(value)?.trim(); - return raw === undefined || raw === "" ? undefined : raw; - }), + Config.map((value) => blankAsUnset(Option.getOrUndefined(value))), ); /** @@ -183,6 +212,24 @@ const readInt = (name: string, warnings: Array) => }), ); +/** + * 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. @@ -196,13 +243,17 @@ const readInt = (name: string, warnings: Array) => 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) { - continue; + return undefined; } const key = member.slice(0, separator).trim(); if (key === "") { - continue; + return undefined; } const value = member.slice(separator + 1).trim(); try { @@ -211,10 +262,13 @@ const parseBaggage = (raw: string): Readonly> | undefined return undefined; } } - // A value that produced no pair at all is a malformed list, not a request - // for no headers. Returning `{}` here would count as a supplied value and - // silently shadow the generic variable the signal should have fallen back - // to. + // 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; }; @@ -264,20 +318,64 @@ const signalEndpoint = (signal: OtlpSignalName) => 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) => +const signalWantsOtlp = (signal: OtlpSignalName, warnings: Array) => optionalString(`OTEL_${signal}_EXPORTER`).pipe( - Effect.map((value) => { - if (value === undefined) { + Effect.map((raw) => { + if (raw === undefined) { return true; } - return value + const name = `OTEL_${signal}_EXPORTER`; + const entries = raw .split(",") .map((entry) => entry.trim().toLowerCase()) - .includes("otlp"); + .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; }), ); @@ -321,17 +419,34 @@ const SIGNAL_BATCHING = { } >; +/** One signal as these variables read it, before the transport decision. */ +interface ReadSignal extends Parsed { + readonly off: boolean; +} + const signalSettings = ( signal: OtlpSignalName, protocol: OtlpProtocol, - temporality: MetricsTemporality | undefined, + /** + * 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, ) => Effect.gen(function* () { const url = yield* signalEndpoint(signal); - if (url === undefined || !(yield* signalWantsOtlp(signal))) { - return { value: undefined, warnings: [] }; + // 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; @@ -341,7 +456,7 @@ const signalSettings = ( const maxBatchSize = batching.maxExportBatchSize === undefined ? undefined - : ((yield* readInt(batching.maxExportBatchSize, numbers)) ?? + : ((yield* readPositiveInt(batching.maxExportBatchSize, numbers)) ?? SPEC_DEFAULT_MAX_EXPORT_BATCH_SIZE); return { value: { @@ -350,10 +465,16 @@ const signalSettings = ( headers, exportIntervalMs, maxBatchSize, - temporality: signal === "METRICS" ? temporality : undefined, + temporality: temporality?.value, }, - warnings: [...specific.warnings, ...generic.warnings, ...numbers], - } satisfies Parsed; + warnings: [ + ...specific.warnings, + ...generic.warnings, + ...numbers, + ...(temporality?.warnings ?? []), + ], + off: false, + } satisfies ReadSignal; }); /** What one signal should do about its wire format. */ @@ -424,8 +545,22 @@ const resolveProtocol = Effect.gen(function* () { /** * `lowmemory` is a real preference in the specification that this exporter - * cannot produce, so it warns and falls back to the default rather than - * pretending it applied. + * 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", @@ -438,12 +573,18 @@ const resolveMetricsTemporality = optionalString( 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: [ - preference === "lowmemory" - ? "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=lowmemory is not supported here; cumulative is used" - : `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=${raw} is not a known preference and was ignored`, + `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=${raw} is not a known preference and was ignored, so metrics are exported as ${DEFAULT_METRICS_TEMPORALITY}`, ], }; }), @@ -494,6 +635,24 @@ const disabledBy = (name: string) => ? "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`; +/** + * 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 }; + /** * Read the environment. Never fails: a variable T3 Code cannot honor * leaves the corresponding setting unset and is reported through the signal's @@ -510,13 +669,13 @@ export const load: Effect.Effect = Effect.gen(function* () { const resource = yield* resolveResource; const temporality = yield* resolveMetricsTemporality; const traces = disabled - ? { value: undefined, warnings: [] } + ? { value: undefined, warnings: [], off: false } : yield* signalSettings("TRACES", protocolDecision.traces.protocol, undefined); const metrics = disabled - ? { value: undefined, warnings: [] } - : yield* signalSettings("METRICS", protocolDecision.metrics.protocol, temporality.value); + ? { value: undefined, warnings: [], off: false } + : yield* signalSettings("METRICS", protocolDecision.metrics.protocol, temporality); const logs = disabled - ? { value: undefined, warnings: [] } + ? { value: undefined, warnings: [], off: false } : yield* signalSettings("LOGS", protocolDecision.logs.protocol, undefined); return { disabled, @@ -530,29 +689,14 @@ export const load: Effect.Effect = Effect.gen(function* () { : []), ...protocolDecision.warnings, ...resource.warnings, - ...temporality.warnings, ...traces.warnings, ...metrics.warnings, ...logs.warnings, ]), ], - // `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. - traces: { - settings: protocolDecision.traces.declined === undefined ? traces.value : undefined, - declined: traces.value === undefined ? undefined : protocolDecision.traces.declined, - }, - metrics: { - settings: protocolDecision.metrics.declined === undefined ? metrics.value : undefined, - declined: metrics.value === undefined ? undefined : protocolDecision.metrics.declined, - }, - logs: { - settings: protocolDecision.logs.declined === undefined ? logs.value : undefined, - declined: logs.value === undefined ? undefined : protocolDecision.logs.declined, - }, + traces: signalOf(traces, protocolDecision.traces), + metrics: signalOf(metrics, protocolDecision.metrics), + logs: signalOf(logs, protocolDecision.logs), resource: resource.value, }; }).pipe( @@ -561,9 +705,9 @@ export const load: Effect.Effect = Effect.gen(function* () { Effect.as({ disabled: false, warnings: [], - traces: { settings: undefined, declined: UNREADABLE }, - metrics: { settings: undefined, declined: UNREADABLE }, - logs: { settings: undefined, declined: UNREADABLE }, + traces: { settings: undefined, declined: UNREADABLE, off: false }, + metrics: { settings: undefined, declined: UNREADABLE, off: false }, + logs: { settings: undefined, declined: UNREADABLE, off: false }, resource: { serviceVersion: undefined, attributes: {} }, }), ), @@ -571,7 +715,114 @@ export const load: Effect.Effect = Effect.gen(function* () { ); /** A signal these variables said nothing usable about. */ -export const noSignal: OtlpSignal = { settings: undefined, declined: undefined }; +const noSignal: OtlpSignal = { settings: undefined, declined: undefined, off: false }; + +/** 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; +} + +/** + * 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. + */ +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 }; + } + if (input.signal.off) { + return { url: undefined, signal: input.signal }; + } + const persistedUrl = blankAsUnset(input.persistedUrl); + return persistedUrl === undefined + ? { url: undefined, signal: input.signal } + : { url: persistedUrl, signal: noSignal }; +}; /** An environment that asked for nothing, for tests and for the pairing CLI. */ export const none: OtelEnvironment = {