From 98a66a68956bdab73581cd7231a44f7d1b60526a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 25 Sep 2026 07:24:56 -0400 Subject: [PATCH 1/8] feat(observability): honor the standard OTLP endpoint, headers, and protocol variables Signed-off-by: Yordis Prieto --- apps/server/src/cli/config.test.ts | 142 ++++++++++++ apps/server/src/cli/config.ts | 46 ++-- docs/operations/observability.md | 10 + packages/shared/src/otelEnvironment.test.ts | 244 ++++++++++++++++++++ packages/shared/src/otelEnvironment.ts | 234 ++++++++++++++++++- 5 files changed, 653 insertions(+), 23 deletions(-) diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index f9b45caf7442..6b42756ad175 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -999,4 +999,146 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpLogsUrl).toBe("http://collector.internal:4318/v1/logs"); }), ); + + const minimalWebFlags = (baseDir: string) => ({ + mode: Option.some("web" as const), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }); + + it.effect( + "resolves each signal's endpoint through T3CODE_OTLP_*_URL, an OTEL endpoint, the bootstrap envelope, and persisted Settings, in that order", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-cli-config-otel-precedence-", + }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, + ); + + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + otlpMetricsUrl: "http://bootstrap:4318/v1/metrics", + // Blank, not an endpoint: it must not stand in front of Settings. + otlpLogsUrl: "", + }), + ); + + const resolved = yield* resolveServerConfig( + { + ...minimalWebFlags(baseDir), + mode: Option.some("desktop"), + port: Option.some(4888), + bootstrapFd: Option.some(fd), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces", + T3CODE_OTLP_HEADERS: "x-key=secret", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://otel-traces:4318/custom", + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "http://otel-metrics:4318/custom", + OTEL_EXPORTER_OTLP_HEADERS: "x-key=otel", + }, + }), + ), + NetService.layer, + ), + ), + ); + + // T3CODE_OTLP_TRACES_URL wins over the OTEL variable for the same + // signal, and keeps T3 Code's own headers since T3 Code still owns it. + expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces"); + expect(resolved.otlpTracesExport.headers).toEqual({ "x-key": "secret" }); + // Metrics named no T3CODE_OTLP_METRICS_URL, so the OTEL endpoint wins + // over the bootstrap envelope and brings the OTEL headers and protocol. + expect(resolved.otlpMetricsUrl).toBe("http://otel-metrics:4318/custom"); + expect(resolved.otlpMetricsExport).toEqual({ + ...DEFAULT_SIGNAL_EXPORT, + protocol: "http/protobuf", + headers: { "x-key": "otel" }, + }); + // Logs named no T3 or OTEL endpoint and a blank bootstrap value, so + // Settings answers, and logs keep the shared headers since no OTEL + // endpoint claimed them. + expect(resolved.otlpLogsUrl).toBe("http://settings:4318/v1/logs"); + expect(resolved.otlpLogsExport.headers).toEqual({ "x-key": "secret" }); + }), + ); + + it.effect( + "exports nothing for a signal an OTEL endpoint claimed with a protocol or headers that do not read", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-cli-config-otel-off-", + }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, + ); + + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ otlpMetricsUrl: "http://bootstrap:4318/v1/metrics" }), + ); + + const resolved = yield* resolveServerConfig( + { + ...minimalWebFlags(baseDir), + mode: Option.some("desktop"), + port: Option.some(4888), + bootstrapFd: Option.some(fd), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel:4318", + OTEL_EXPORTER_OTLP_HEADERS: "x-key=%zz", + }, + }), + ), + NetService.layer, + ), + ), + ); + + // T3CODE_OTLP_TRACES_URL still wins outright. + expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces"); + // The OTEL endpoint claimed metrics and logs, so neither the bootstrap + // envelope nor Settings receives them with T3 Code's headers. + expect(resolved.otlpMetricsUrl).toBeUndefined(); + expect(resolved.otlpLogsUrl).toBeUndefined(); + }), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 1b6449433139..747370413a77 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -390,12 +390,34 @@ export const resolveServerConfig = ( const otel = yield* OtelEnvironment.load; // T3 Code's own OTLP variables name no signal, so the one answer they give - // is the answer for all three. + // is the answer for all three, unless an OTEL endpoint claims one below. const signalExport: SignalExport = { protocol: env.otlpProtocol, headers: env.otlpHeaders, exportIntervalMs: env.otlpExportIntervalMs, }; + const t3 = (url: string | undefined) => ({ url, export: signalExport }); + const traces = OtelEnvironment.resolveSignalEndpoint( + otel, + "traces", + t3(env.otlpTracesUrl), + bootstrap?.otlpTracesUrl, + persistedObservabilitySettings.otlpTracesUrl, + ); + const metrics = OtelEnvironment.resolveSignalEndpoint( + otel, + "metrics", + t3(env.otlpMetricsUrl), + bootstrap?.otlpMetricsUrl, + persistedObservabilitySettings.otlpMetricsUrl, + ); + const logs = OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + t3(env.otlpLogsUrl), + bootstrap?.otlpLogsUrl, + persistedObservabilitySettings.otlpLogsUrl, + ); const config: ServerConfig.ServerConfig["Service"] = { logLevel, @@ -404,22 +426,12 @@ export const resolveServerConfig = ( traceBatchWindowMs: env.traceBatchWindowMs, traceMaxBytes: env.traceMaxBytes, traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: otel.disabled - ? undefined - : (env.otlpTracesUrl ?? - bootstrap?.otlpTracesUrl ?? - persistedObservabilitySettings.otlpTracesUrl), - otlpMetricsUrl: otel.disabled - ? undefined - : (env.otlpMetricsUrl ?? - bootstrap?.otlpMetricsUrl ?? - persistedObservabilitySettings.otlpMetricsUrl), - otlpLogsUrl: otel.disabled - ? undefined - : (env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl), - otlpTracesExport: signalExport, - otlpMetricsExport: signalExport, - otlpLogsExport: signalExport, + otlpTracesUrl: traces?.url, + otlpMetricsUrl: metrics?.url, + otlpLogsUrl: logs?.url, + otlpTracesExport: traces?.export ?? signalExport, + otlpMetricsExport: metrics?.export ?? signalExport, + otlpLogsExport: logs?.export ?? signalExport, otlpServiceName: env.otlpServiceName, otelEnvironment: otel, mode, diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 97c6936ba608..975d0d3f5198 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -552,6 +552,16 @@ OTLP export: `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values. - `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf` +The standard `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` and generic +`OTEL_EXPORTER_OTLP_ENDPOINT` (with `/v1/traces`, `/v1/metrics`, or `/v1/logs` appended) also work, +for a collector expecting those instead. A `T3CODE_OTLP_*_URL` wins over either when both are set. +A signal an OTEL endpoint configured takes its headers from `OTEL_EXPORTER_OTLP_HEADERS` and its +protocol from `OTEL_EXPORTER_OTLP_PROTOCOL` (default `http/protobuf`), and a per-signal +`OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` or `_PROTOCOL` wins over the generic one for its +signal. `T3CODE_OTLP_HEADERS` and `T3CODE_OTLP_PROTOCOL` never apply to it. A protocol other than +`http/protobuf` or `http/json`, such as `grpc`, or headers that are not percent-encoded, turn that +signal's export off with a startup warning, rather than sending it to the Settings endpoint. + If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay on stdout only. diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts index 548f2a78b776..1ec41f68515c 100644 --- a/packages/shared/src/otelEnvironment.test.ts +++ b/packages/shared/src/otelEnvironment.test.ts @@ -106,6 +106,250 @@ describe("OtelEnvironment", () => { }), ); + describe("endpoints", () => { + const urlOf = (signal: OtelEnvironment.OtelSignal) => + signal._tag === "Export" ? signal.url : signal._tag; + it.effect.each([ + { + name: "nothing set", + env: {}, + traces: "Unset", + metrics: "Unset", + logs: "Unset", + warnings: [], + }, + { + name: "generic endpoint appends each signal's path, keeping the query", + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base?api_key=secret" }, + traces: "https://collector:4318/base/v1/traces?api_key=secret", + metrics: "https://collector:4318/base/v1/metrics?api_key=secret", + logs: "https://collector:4318/base/v1/logs?api_key=secret", + warnings: [], + }, + { + name: "a per-signal endpoint is used verbatim", + env: { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/custom" }, + traces: "https://tempo:4318/custom", + metrics: "Unset", + logs: "Unset", + warnings: [], + }, + { + name: "a per-signal endpoint beats the generic one", + env: { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/custom", + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", + }, + traces: "https://tempo:4318/custom", + metrics: "https://collector:4318/base/v1/metrics", + logs: "https://collector:4318/base/v1/logs", + warnings: [], + }, + { + name: "a blank per-signal endpoint falls through to the generic one", + env: { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: " ", + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", + }, + traces: "https://collector:4318/base/v1/traces", + metrics: "https://collector:4318/base/v1/metrics", + logs: "https://collector:4318/base/v1/logs", + warnings: [], + }, + { + name: "an invalid per-signal endpoint warns and does not fall through", + env: { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "not-a-url", + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", + }, + traces: "Unset", + metrics: "https://collector:4318/base/v1/metrics", + logs: "https://collector:4318/base/v1/logs", + warnings: ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is not a URL and was ignored"], + }, + { + name: "an invalid generic endpoint warns without leaking its query", + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "not-a-url?api_key=secret" }, + traces: "Unset", + metrics: "Unset", + logs: "Unset", + warnings: ["OTEL_EXPORTER_OTLP_ENDPOINT is not a URL and was ignored"], + }, + { + name: "the kill switch wins outright over a valid endpoint", + env: { + T3CODE_OTEL_SDK_DISABLED: "true", + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", + }, + traces: "Unset", + metrics: "Unset", + logs: "Unset", + warnings: [T3_OFF], + }, + ])("$name", ({ env, traces, metrics, logs, warnings }) => + Effect.gen(function* () { + const resolved = yield* load(env); + assert.strictEqual(urlOf(resolved.traces), traces); + assert.strictEqual(urlOf(resolved.metrics), metrics); + assert.strictEqual(urlOf(resolved.logs), logs); + assert.deepStrictEqual(resolved.warnings, warnings); + }), + ); + + const ENDPOINT = { OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318" }; + const exportOf = (signal: OtelEnvironment.OtelSignal): unknown => + signal._tag === "Export" + ? { protocol: signal.protocol, headers: signal.headers } + : signal._tag; + it.effect.each([ + { + name: "nothing else set takes the specification's default protocol", + env: ENDPOINT, + traces: { protocol: "http/protobuf", headers: undefined }, + logs: { protocol: "http/protobuf", headers: undefined }, + warnings: [], + }, + { + name: "headers are comma-separated pairs with percent-encoded values", + env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS: "api-key=a%20b,tenant=t3" }, + traces: { protocol: "http/protobuf", headers: { "api-key": "a b", tenant: "t3" } }, + logs: { protocol: "http/protobuf", headers: { "api-key": "a b", tenant: "t3" } }, + warnings: [], + }, + { + name: "a per-signal protocol and headers beat the generic ones", + env: { + ...ENDPOINT, + OTEL_EXPORTER_OTLP_PROTOCOL: "http/json", + OTEL_EXPORTER_OTLP_HEADERS: "api-key=shared", + OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_TRACES_HEADERS: "api-key=traces%20only", + }, + traces: { protocol: "http/protobuf", headers: { "api-key": "traces only" } }, + logs: { protocol: "http/json", headers: { "api-key": "shared" } }, + warnings: [], + }, + { + name: "an unsupported protocol turns off the signals it configures", + env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc" }, + traces: { protocol: "http/protobuf", headers: undefined }, + logs: "Off", + warnings: [ + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=grpc is not http/protobuf or http/json, so the signals it configures are not exported", + ], + }, + { + name: "undecodable headers turn off every signal once, without leaking them", + env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz" }, + traces: "Off", + logs: "Off", + warnings: [ + "OTEL_EXPORTER_OTLP_HEADERS has a value that is not percent-encoded, so the signals it configures are not exported", + ], + }, + { + name: "generic headers every signal overrides say nothing", + env: { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/v1/traces", + OTEL_EXPORTER_OTLP_TRACES_HEADERS: "api-key=traces", + OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz", + }, + traces: { protocol: "http/protobuf", headers: { "api-key": "traces" } }, + logs: "Unset", + warnings: [], + }, + { + name: "protocol and headers say nothing for a signal no endpoint names", + env: { OTEL_EXPORTER_OTLP_PROTOCOL: "grpc", OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz" }, + traces: "Unset", + logs: "Unset", + warnings: [], + }, + ])("$name", ({ env, traces, logs, warnings }) => + Effect.gen(function* () { + const resolved = yield* load(env); + assert.deepStrictEqual(exportOf(resolved.traces), traces); + assert.deepStrictEqual(exportOf(resolved.logs), logs); + assert.deepStrictEqual(resolved.warnings, warnings); + }), + ); + }); + + describe("resolveSignalEndpoint", () => { + const t3Export = { + protocol: "http/json", + headers: { "x-key": "t3" }, + exportIntervalMs: 5_000, + } as const; + const withLogs = (logs: OtelEnvironment.OtelSignal, disabled = false) => ({ + ...OtelEnvironment.none, + disabled, + logs, + }); + const otelExport: OtelEnvironment.OtelSignal = { + _tag: "Export", + url: "http://otel:4318/v1/logs", + protocol: "http/protobuf", + headers: { "x-key": "otel" }, + }; + it.each([ + { + name: "T3CODE_OTLP_*_URL wins over an OTEL endpoint", + otel: withLogs(otelExport), + t3Url: "http://t3:4318/v1/logs", + expected: { url: "http://t3:4318/v1/logs", export: t3Export }, + }, + { + name: "T3CODE_OTLP_*_URL wins over a signal the OTEL variables turned off", + otel: withLogs({ _tag: "Off" }), + t3Url: "http://t3:4318/v1/logs", + expected: { url: "http://t3:4318/v1/logs", export: t3Export }, + }, + { + name: "an OTEL endpoint brings its headers and protocol over the fallback", + otel: withLogs(otelExport), + t3Url: " ", + expected: { + url: "http://otel:4318/v1/logs", + export: { + protocol: "http/protobuf" as const, + headers: { "x-key": "otel" }, + exportIntervalMs: 5_000, + }, + }, + }, + { + name: "a signal the OTEL variables turned off does not fall through", + otel: withLogs({ _tag: "Off" }), + t3Url: undefined, + expected: undefined, + }, + { + name: "an unset signal takes the first non-blank fallback", + otel: withLogs({ _tag: "Unset" }), + t3Url: undefined, + expected: { url: "http://settings:4318/v1/logs", export: t3Export }, + }, + { + name: "the kill switch wins over everything", + otel: withLogs(otelExport, true), + t3Url: "http://t3:4318/v1/logs", + expected: undefined, + }, + ])("$name", ({ otel, t3Url, expected }) => { + assert.deepStrictEqual( + OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + { url: t3Url, export: t3Export }, + "", + "http://settings:4318/v1/logs", + ), + expected, + ); + }); + }); + describe("layerResourceAttributes", () => { it.effect.each([ { name: "a list that does not decode", raw: "team=%zz", attributes: [] }, diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index a10b3be4e278..378717e54311 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -1,6 +1,7 @@ /** - * otelEnvironment: the OpenTelemetry kill switch, shared by the server and the - * desktop main process so both agree on what turns export off. + * otelEnvironment: the OpenTelemetry kill switch and endpoint variables, + * shared by the server and the desktop main process so both agree on what + * turns export off and where it goes. * * `T3CODE_OTEL_SDK_DISABLED` is read first, so a machine that sets * `OTEL_SDK_DISABLED` for everything else can still opt T3 Code back in. @@ -10,9 +11,33 @@ import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; +import { OtlpProtocol, type SignalExport } from "./observability.ts"; + +/** The signals T3 Code exports, spelled as the variable names spell them. */ +type OtlpSignalName = "TRACES" | "METRICS" | "LOGS"; + +/** + * What the OTEL variables say about one signal. `Off` is a signal they + * claimed with an endpoint whose protocol or headers do not read, so it is + * exported nowhere rather than to whatever collector is configured below it. + */ +export type OtelSignal = + | { readonly _tag: "Unset" } + | { readonly _tag: "Off" } + | { + readonly _tag: "Export"; + readonly url: string; + readonly protocol: OtlpProtocol; + readonly headers: Readonly> | undefined; + }; + +const UNSET: OtelSignal = { _tag: "Unset" }; +const OFF: OtelSignal = { _tag: "Off" }; + export interface OtelEnvironment { /** Whether OTLP export is off, whatever endpoint is configured. */ readonly disabled: boolean; @@ -20,8 +45,17 @@ export interface OtelEnvironment { readonly warnings: ReadonlyArray; /** `OTEL_RESOURCE_ATTRIBUTES`, or nothing when it could not be read. */ readonly resourceAttributes: Readonly>; + readonly traces: OtelSignal; + readonly metrics: OtelSignal; + readonly logs: OtelSignal; } +/** A set but blank value reads as unset, so the source under it can answer. */ +const blankAsUnset = (value: string | undefined): string | undefined => { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed === "" ? undefined : trimmed; +}; + interface Flag { /** `undefined` when the variable is unset, blank, or unreadable. */ readonly value: boolean | undefined; @@ -93,6 +127,119 @@ const resourceAttributes = Config.Record( Config.withDefault({ value: {} }), ); +interface Setting { + readonly value: A | undefined; + readonly warning?: string; +} + +/** Reads one variable, warning rather than failing when it is set and unusable. */ +const setting = ( + config: Config.Config, + name: string, + warning: (raw: string) => string, +): Config.Config> => + config.pipe( + Config.map((value): Setting => ({ value })), + Config.orElse(() => + Config.String(name).pipe( + Config.option, + Config.map((raw): Setting => { + const value = blankAsUnset(Option.getOrUndefined(raw)); + return value === undefined + ? { value: undefined } + : { value: undefined, warning: warning(value) }; + }), + ), + ), + ); + +const endpoint = (name: string) => + setting( + Config.URL(name), + name, + // The value is left out because an endpoint can carry an API key. + () => `${name} is not a URL and was ignored`, + ); + +const protocol = (name: string) => + setting( + Config.schema(OtlpProtocol, name), + name, + (raw) => + `${name}=${raw} is not http/protobuf or http/json, so the signals it configures are not exported`, + ); + +const headers = (name: string) => + setting( + // The schema Effect's OTLP exporters read these variables with. + Config.Record(Schema.String, Schema.StringFromUriComponent, name), + name, + // The value is left out because headers carry credentials. + () => + `${name} has a value that is not percent-encoded, so the signals it configures are not exported`, + ); + +interface Settings { + readonly endpoint: Setting; + readonly protocol: Setting; + readonly headers: Setting>>; +} + +const settings = (prefix: string): Config.Config => + Config.all({ + endpoint: endpoint(`${prefix}ENDPOINT`), + protocol: protocol(`${prefix}PROTOCOL`), + headers: headers(`${prefix}HEADERS`), + }); + +/** The signal's own variable claims the signal once set, valid or not. */ +const isClaimed = (setting: Setting) => + setting.value !== undefined || setting.warning !== undefined; + +const claimed = (own: Setting, generic: Setting) => (isClaimed(own) ? own : generic); + +/** Appends the signal's path, keeping the query an intake may take its API key in. */ +const withSignalPath = (signal: OtlpSignalName, base: URL) => { + const url = new URL(base); + const slash = url.pathname.endsWith("/") ? "" : "/"; + url.pathname += `${slash}v1/${signal.toLowerCase()}`; + return url; +}; + +interface ResolvedSignal { + readonly signal: OtelSignal; + /** The settings this signal read, whose warnings are the signal's to report. */ + readonly used: ReadonlyArray>; +} + +/** + * A signal whose protocol or headers do not read is not exported rather than + * sent in a format or without the credentials its collector expects. + */ +const signal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => { + const ownEndpoint = isClaimed(own.endpoint); + const endpoint = ownEndpoint ? own.endpoint : generic.endpoint; + if (endpoint.value === undefined) { + return { signal: UNSET, used: [endpoint] }; + } + const protocol = claimed(own.protocol, generic.protocol); + const headers = claimed(own.headers, generic.headers); + const used = [endpoint, protocol, headers]; + if (protocol.warning !== undefined || headers.warning !== undefined) { + return { signal: OFF, used }; + } + const url = ownEndpoint ? endpoint.value : withSignalPath(name, endpoint.value); + return { + signal: { + _tag: "Export", + url: url.toString(), + protocol: protocol.value ?? "http/protobuf", + headers: headers.value, + }, + used, + }; +}; + export const load: Effect.Effect = Config.all({ t3: flag( "T3CODE_OTEL_SDK_DISABLED", @@ -111,12 +258,31 @@ export const load: Effect.Effect = Config.all({ `OTEL_SDK_DISABLED=${value} was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way`, ), resource: resourceAttributes, + generic: settings("OTEL_EXPORTER_OTLP_"), + traces: settings("OTEL_EXPORTER_OTLP_TRACES_"), + metrics: settings("OTEL_EXPORTER_OTLP_METRICS_"), + logs: settings("OTEL_EXPORTER_OTLP_LOGS_"), }).pipe( - Effect.map(({ t3, spec, resource }) => { + Effect.map(({ t3, spec, resource, generic, ...own }) => { const disabled = t3.value ?? spec.value ?? false; - const warnings = [t3.warning, spec.warning, resource.warning].filter( - (warning) => warning !== undefined, + // The kill switch wins outright, so the signals say nothing once it is set. + const signals = disabled + ? undefined + : { + traces: signal("TRACES", own.traces, generic), + metrics: signal("METRICS", own.metrics, generic), + logs: signal("LOGS", own.logs, generic), + }; + // A generic variable read by several signals warns once. + const used = new Set( + signals === undefined ? [] : Object.values(signals).flatMap((resolved) => resolved.used), ); + const warnings = [ + t3.warning, + spec.warning, + resource.warning, + ...Array.from(used, (setting) => setting.warning), + ].filter((warning) => warning !== undefined); if (disabled) { warnings.push( t3.value @@ -124,12 +290,65 @@ export const load: Effect.Effect = Config.all({ : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway", ); } - return { disabled, warnings, resourceAttributes: resource.value }; + return { + disabled, + warnings, + resourceAttributes: resource.value, + traces: signals?.traces.signal ?? UNSET, + metrics: signals?.metrics.signal ?? UNSET, + logs: signals?.logs.signal ?? UNSET, + }; }), // Every read above falls back instead of failing, so this cannot happen. Effect.orDie, ); +export type SignalName = "traces" | "metrics" | "logs"; + +export interface SignalEndpoint { + readonly url: string; + readonly export: SignalExport; +} + +/** + * Where one signal exports and how. `T3CODE_OTLP_*_URL` wins outright with + * T3 Code's own export, then an OTEL endpoint with its own headers and + * protocol, since `T3CODE_OTLP_HEADERS` was written for a different + * collector, then the first of `fallbackUrls` with T3 Code's own export. + */ +export const resolveSignalEndpoint = ( + otel: OtelEnvironment, + signal: SignalName, + t3: { readonly url: string | undefined; readonly export: SignalExport }, + ...fallbackUrls: ReadonlyArray +): SignalEndpoint | undefined => { + if (otel.disabled) { + return undefined; + } + const t3Url = blankAsUnset(t3.url); + if (t3Url !== undefined) { + return { url: t3Url, export: t3.export }; + } + const claimedByOtel = otel[signal]; + switch (claimedByOtel._tag) { + case "Export": + return { + url: claimedByOtel.url, + export: { + protocol: claimedByOtel.protocol, + headers: claimedByOtel.headers, + exportIntervalMs: t3.export.exportIntervalMs, + }, + }; + case "Off": + return undefined; + case "Unset": { + const url = fallbackUrls.map(blankAsUnset).find((candidate) => candidate !== undefined); + return url === undefined ? undefined : { url, export: t3.export }; + } + } +}; + /** * Provide this around Effect's OTLP exporters, which read * `OTEL_RESOURCE_ATTRIBUTES` for themselves and die when it does not decode, @@ -154,4 +373,7 @@ export const none: OtelEnvironment = { disabled: false, warnings: [], resourceAttributes: {}, + traces: UNSET, + metrics: UNSET, + logs: UNSET, }; From 56ca96071de934b0c2cec209898f749b2fb126d4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 25 Sep 2026 07:36:39 -0400 Subject: [PATCH 2/8] refactor(server): inline the T3 signal source Signed-off-by: Yordis Prieto --- apps/server/src/cli/config.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 747370413a77..05e60a082557 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -396,25 +396,24 @@ export const resolveServerConfig = ( headers: env.otlpHeaders, exportIntervalMs: env.otlpExportIntervalMs, }; - const t3 = (url: string | undefined) => ({ url, export: signalExport }); const traces = OtelEnvironment.resolveSignalEndpoint( otel, "traces", - t3(env.otlpTracesUrl), + { url: env.otlpTracesUrl, export: signalExport }, bootstrap?.otlpTracesUrl, persistedObservabilitySettings.otlpTracesUrl, ); const metrics = OtelEnvironment.resolveSignalEndpoint( otel, "metrics", - t3(env.otlpMetricsUrl), + { url: env.otlpMetricsUrl, export: signalExport }, bootstrap?.otlpMetricsUrl, persistedObservabilitySettings.otlpMetricsUrl, ); const logs = OtelEnvironment.resolveSignalEndpoint( otel, "logs", - t3(env.otlpLogsUrl), + { url: env.otlpLogsUrl, export: signalExport }, bootstrap?.otlpLogsUrl, persistedObservabilitySettings.otlpLogsUrl, ); From f64fc7ac8d6e40f984ed2720ad7b639df370d97a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 25 Sep 2026 07:37:22 -0400 Subject: [PATCH 3/8] docs(observability): drop positional wording from comments Signed-off-by: Yordis Prieto --- apps/server/src/cli/config.ts | 2 +- packages/shared/src/otelEnvironment.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 05e60a082557..9c1ceac82101 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -390,7 +390,7 @@ export const resolveServerConfig = ( const otel = yield* OtelEnvironment.load; // T3 Code's own OTLP variables name no signal, so the one answer they give - // is the answer for all three, unless an OTEL endpoint claims one below. + // is the answer for all three. const signalExport: SignalExport = { protocol: env.otlpProtocol, headers: env.otlpHeaders, diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index 378717e54311..6c8f5e470ccf 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -23,7 +23,7 @@ type OtlpSignalName = "TRACES" | "METRICS" | "LOGS"; /** * What the OTEL variables say about one signal. `Off` is a signal they * claimed with an endpoint whose protocol or headers do not read, so it is - * exported nowhere rather than to whatever collector is configured below it. + * exported nowhere rather than to the bootstrap or Settings collector. */ export type OtelSignal = | { readonly _tag: "Unset" } From 9a747a77f38cbebadbbe6085b4bc131875491a04 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 25 Sep 2026 07:37:54 -0400 Subject: [PATCH 4/8] refactor(shared): name the tolerant OTEL reader readOrWarn Signed-off-by: Yordis Prieto --- packages/shared/src/otelEnvironment.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index 6c8f5e470ccf..e35fc86bebc8 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -133,7 +133,7 @@ interface Setting { } /** Reads one variable, warning rather than failing when it is set and unusable. */ -const setting = ( +const readOrWarn = ( config: Config.Config, name: string, warning: (raw: string) => string, @@ -154,7 +154,7 @@ const setting = ( ); const endpoint = (name: string) => - setting( + readOrWarn( Config.URL(name), name, // The value is left out because an endpoint can carry an API key. @@ -162,7 +162,7 @@ const endpoint = (name: string) => ); const protocol = (name: string) => - setting( + readOrWarn( Config.schema(OtlpProtocol, name), name, (raw) => @@ -170,7 +170,7 @@ const protocol = (name: string) => ); const headers = (name: string) => - setting( + readOrWarn( // The schema Effect's OTLP exporters read these variables with. Config.Record(Schema.String, Schema.StringFromUriComponent, name), name, From 484c0c5c59baa08bf9019bdb20ee6b2de59211d7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 25 Sep 2026 07:40:29 -0400 Subject: [PATCH 5/8] refactor(shared): model OTEL signal state as a Data.TaggedEnum Signed-off-by: Yordis Prieto --- packages/shared/src/otelEnvironment.test.ts | 25 +++++--- packages/shared/src/otelEnvironment.ts | 69 +++++++++------------ 2 files changed, 45 insertions(+), 49 deletions(-) diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts index 1ec41f68515c..79fd201d85fd 100644 --- a/packages/shared/src/otelEnvironment.test.ts +++ b/packages/shared/src/otelEnvironment.test.ts @@ -108,7 +108,11 @@ describe("OtelEnvironment", () => { describe("endpoints", () => { const urlOf = (signal: OtelEnvironment.OtelSignal) => - signal._tag === "Export" ? signal.url : signal._tag; + OtelEnvironment.OtelSignal.$match(signal, { + Export: ({ url }) => url, + Off: () => "Off", + Unset: () => "Unset", + }); it.effect.each([ { name: "nothing set", @@ -198,9 +202,11 @@ describe("OtelEnvironment", () => { const ENDPOINT = { OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318" }; const exportOf = (signal: OtelEnvironment.OtelSignal): unknown => - signal._tag === "Export" - ? { protocol: signal.protocol, headers: signal.headers } - : signal._tag; + OtelEnvironment.OtelSignal.$match(signal, { + Export: ({ protocol, headers }) => ({ protocol, headers }), + Off: () => "Off", + Unset: () => "Unset", + }); it.effect.each([ { name: "nothing else set takes the specification's default protocol", @@ -286,12 +292,11 @@ describe("OtelEnvironment", () => { disabled, logs, }); - const otelExport: OtelEnvironment.OtelSignal = { - _tag: "Export", + const otelExport = OtelEnvironment.OtelSignal.Export({ url: "http://otel:4318/v1/logs", protocol: "http/protobuf", headers: { "x-key": "otel" }, - }; + }); it.each([ { name: "T3CODE_OTLP_*_URL wins over an OTEL endpoint", @@ -301,7 +306,7 @@ describe("OtelEnvironment", () => { }, { name: "T3CODE_OTLP_*_URL wins over a signal the OTEL variables turned off", - otel: withLogs({ _tag: "Off" }), + otel: withLogs(OtelEnvironment.OtelSignal.Off()), t3Url: "http://t3:4318/v1/logs", expected: { url: "http://t3:4318/v1/logs", export: t3Export }, }, @@ -320,13 +325,13 @@ describe("OtelEnvironment", () => { }, { name: "a signal the OTEL variables turned off does not fall through", - otel: withLogs({ _tag: "Off" }), + otel: withLogs(OtelEnvironment.OtelSignal.Off()), t3Url: undefined, expected: undefined, }, { name: "an unset signal takes the first non-blank fallback", - otel: withLogs({ _tag: "Unset" }), + otel: withLogs(OtelEnvironment.OtelSignal.Unset()), t3Url: undefined, expected: { url: "http://settings:4318/v1/logs", export: t3Export }, }, diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index e35fc86bebc8..8242e9c2deec 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -10,6 +10,7 @@ */ import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -25,18 +26,16 @@ type OtlpSignalName = "TRACES" | "METRICS" | "LOGS"; * claimed with an endpoint whose protocol or headers do not read, so it is * exported nowhere rather than to the bootstrap or Settings collector. */ -export type OtelSignal = - | { readonly _tag: "Unset" } - | { readonly _tag: "Off" } - | { - readonly _tag: "Export"; - readonly url: string; - readonly protocol: OtlpProtocol; - readonly headers: Readonly> | undefined; - }; - -const UNSET: OtelSignal = { _tag: "Unset" }; -const OFF: OtelSignal = { _tag: "Off" }; +export type OtelSignal = Data.TaggedEnum<{ + Unset: {}; + Off: {}; + Export: { + readonly url: string; + readonly protocol: OtlpProtocol; + readonly headers: Readonly> | undefined; + }; +}>; +export const OtelSignal = Data.taggedEnum(); export interface OtelEnvironment { /** Whether OTLP export is off, whatever endpoint is configured. */ @@ -220,22 +219,21 @@ const signal = (name: OtlpSignalName, own: Settings, generic: Settings): Resolve const ownEndpoint = isClaimed(own.endpoint); const endpoint = ownEndpoint ? own.endpoint : generic.endpoint; if (endpoint.value === undefined) { - return { signal: UNSET, used: [endpoint] }; + return { signal: OtelSignal.Unset(), used: [endpoint] }; } const protocol = claimed(own.protocol, generic.protocol); const headers = claimed(own.headers, generic.headers); const used = [endpoint, protocol, headers]; if (protocol.warning !== undefined || headers.warning !== undefined) { - return { signal: OFF, used }; + return { signal: OtelSignal.Off(), used }; } const url = ownEndpoint ? endpoint.value : withSignalPath(name, endpoint.value); return { - signal: { - _tag: "Export", + signal: OtelSignal.Export({ url: url.toString(), protocol: protocol.value ?? "http/protobuf", headers: headers.value, - }, + }), used, }; }; @@ -294,9 +292,9 @@ export const load: Effect.Effect = Config.all({ disabled, warnings, resourceAttributes: resource.value, - traces: signals?.traces.signal ?? UNSET, - metrics: signals?.metrics.signal ?? UNSET, - logs: signals?.logs.signal ?? UNSET, + traces: signals?.traces.signal ?? OtelSignal.Unset(), + metrics: signals?.metrics.signal ?? OtelSignal.Unset(), + logs: signals?.logs.signal ?? OtelSignal.Unset(), }; }), // Every read above falls back instead of failing, so this cannot happen. @@ -329,24 +327,17 @@ export const resolveSignalEndpoint = ( if (t3Url !== undefined) { return { url: t3Url, export: t3.export }; } - const claimedByOtel = otel[signal]; - switch (claimedByOtel._tag) { - case "Export": - return { - url: claimedByOtel.url, - export: { - protocol: claimedByOtel.protocol, - headers: claimedByOtel.headers, - exportIntervalMs: t3.export.exportIntervalMs, - }, - }; - case "Off": - return undefined; - case "Unset": { + return OtelSignal.$match(otel[signal], { + Export: ({ url, protocol, headers }): SignalEndpoint => ({ + url, + export: { protocol, headers, exportIntervalMs: t3.export.exportIntervalMs }, + }), + Off: () => undefined, + Unset: () => { const url = fallbackUrls.map(blankAsUnset).find((candidate) => candidate !== undefined); return url === undefined ? undefined : { url, export: t3.export }; - } - } + }, + }); }; /** @@ -373,7 +364,7 @@ export const none: OtelEnvironment = { disabled: false, warnings: [], resourceAttributes: {}, - traces: UNSET, - metrics: UNSET, - logs: UNSET, + traces: OtelSignal.Unset(), + metrics: OtelSignal.Unset(), + logs: OtelSignal.Unset(), }; From bddcd27d561f7e80ff3627026661e13286f426c3 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 25 Sep 2026 08:18:22 -0400 Subject: [PATCH 6/8] fix(observability): turn off a signal whose OTEL endpoint, protocol, or headers do not read Signed-off-by: Yordis Prieto --- docs/operations/observability.md | 20 +++--- packages/shared/src/otelEnvironment.test.ts | 57 +++++++++++++--- packages/shared/src/otelEnvironment.ts | 73 +++++++++++---------- 3 files changed, 98 insertions(+), 52 deletions(-) diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 975d0d3f5198..5631084f8bbd 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -552,15 +552,17 @@ OTLP export: `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values. - `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf` -The standard `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` and generic -`OTEL_EXPORTER_OTLP_ENDPOINT` (with `/v1/traces`, `/v1/metrics`, or `/v1/logs` appended) also work, -for a collector expecting those instead. A `T3CODE_OTLP_*_URL` wins over either when both are set. -A signal an OTEL endpoint configured takes its headers from `OTEL_EXPORTER_OTLP_HEADERS` and its -protocol from `OTEL_EXPORTER_OTLP_PROTOCOL` (default `http/protobuf`), and a per-signal -`OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` or `_PROTOCOL` wins over the generic one for its -signal. `T3CODE_OTLP_HEADERS` and `T3CODE_OTLP_PROTOCOL` never apply to it. A protocol other than -`http/protobuf` or `http/json`, such as `grpc`, or headers that are not percent-encoded, turn that -signal's export off with a startup warning, rather than sending it to the Settings endpoint. +The server also reads the standard `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` and generic +`OTEL_EXPORTER_OTLP_ENDPOINT` (with `/v1/traces`, `/v1/metrics`, or `/v1/logs` appended), for a +collector expecting those instead. A non-blank `T3CODE_OTLP_*_URL` wins over either, and a blank one +counts as unset. A signal with an OTEL endpoint takes its headers from `OTEL_EXPORTER_OTLP_HEADERS` +and its protocol from `OTEL_EXPORTER_OTLP_PROTOCOL` (default `http/protobuf`, read +case-insensitively), and a per-signal `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` or +`_PROTOCOL` wins over the generic one for its signal. `T3CODE_OTLP_HEADERS` and +`T3CODE_OTLP_PROTOCOL` never apply to it. An endpoint that is not an `http` or `https` URL, a +protocol other than `http/protobuf` or `http/json` such as `grpc`, or headers that are not +`key=value` pairs with percent-encoded values turn that signal's export off with a startup warning, +rather than sending it to the Settings endpoint. If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay on stdout only. diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts index 79fd201d85fd..cd4e683faa6b 100644 --- a/packages/shared/src/otelEnvironment.test.ts +++ b/packages/shared/src/otelEnvironment.test.ts @@ -166,18 +166,32 @@ describe("OtelEnvironment", () => { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "not-a-url", OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", }, - traces: "Unset", + traces: "Off", metrics: "https://collector:4318/base/v1/metrics", logs: "https://collector:4318/base/v1/logs", - warnings: ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is not a URL and was ignored"], + warnings: [ + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is not an http or https URL, so the signals it configures are not exported", + ], }, { name: "an invalid generic endpoint warns without leaking its query", env: { OTEL_EXPORTER_OTLP_ENDPOINT: "not-a-url?api_key=secret" }, - traces: "Unset", - metrics: "Unset", - logs: "Unset", - warnings: ["OTEL_EXPORTER_OTLP_ENDPOINT is not a URL and was ignored"], + traces: "Off", + metrics: "Off", + logs: "Off", + warnings: [ + "OTEL_EXPORTER_OTLP_ENDPOINT is not an http or https URL, so the signals it configures are not exported", + ], + }, + { + name: "an endpoint without a scheme is not an http URL", + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "localhost:4318" }, + traces: "Off", + metrics: "Off", + logs: "Off", + warnings: [ + "OTEL_EXPORTER_OTLP_ENDPOINT is not an http or https URL, so the signals it configures are not exported", + ], }, { name: "the kill switch wins outright over a valid endpoint", @@ -241,18 +255,45 @@ describe("OtelEnvironment", () => { traces: { protocol: "http/protobuf", headers: undefined }, logs: "Off", warnings: [ - "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=grpc is not http/protobuf or http/json, so the signals it configures are not exported", + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL is not http/protobuf or http/json, so the signals it configures are not exported", ], }, + { + name: "a protocol reads case-insensitively", + env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL: "HTTP/JSON" }, + traces: { protocol: "http/json", headers: undefined }, + logs: { protocol: "http/json", headers: undefined }, + warnings: [], + }, { name: "undecodable headers turn off every signal once, without leaking them", env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz" }, traces: "Off", logs: "Off", warnings: [ - "OTEL_EXPORTER_OTLP_HEADERS has a value that is not percent-encoded, so the signals it configures are not exported", + "OTEL_EXPORTER_OTLP_HEADERS is not a list of key=value pairs with percent-encoded values, so the signals it configures are not exported", + ], + }, + { + name: "a header without a value separator turns its signal off", + env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_HEADERS: "Authorization" }, + traces: { protocol: "http/protobuf", headers: undefined }, + logs: "Off", + warnings: [ + "OTEL_EXPORTER_OTLP_LOGS_HEADERS is not a list of key=value pairs with percent-encoded values, so the signals it configures are not exported", ], }, + { + name: "blank per-signal headers leave the generic ones in charge", + env: { + ...ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS: "api-key=shared", + OTEL_EXPORTER_OTLP_LOGS_HEADERS: " ", + }, + traces: { protocol: "http/protobuf", headers: { "api-key": "shared" } }, + logs: { protocol: "http/protobuf", headers: { "api-key": "shared" } }, + warnings: [], + }, { name: "generic headers every signal overrides say nothing", env: { diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index 8242e9c2deec..05a787dcfede 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -16,14 +16,14 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { OtlpProtocol, type SignalExport } from "./observability.ts"; +import { OtlpHeadersFromString, OtlpProtocol, type SignalExport } from "./observability.ts"; /** The signals T3 Code exports, spelled as the variable names spell them. */ type OtlpSignalName = "TRACES" | "METRICS" | "LOGS"; /** * What the OTEL variables say about one signal. `Off` is a signal they - * claimed with an endpoint whose protocol or headers do not read, so it is + * claimed with an endpoint, protocol, or headers that do not read, so it is * exported nowhere rather than to the bootstrap or Settings collector. */ export type OtelSignal = Data.TaggedEnum<{ @@ -131,51 +131,52 @@ interface Setting { readonly warning?: string; } -/** Reads one variable, warning rather than failing when it is set and unusable. */ +/** + * Reads one variable. Blank reads as unset, and a value `parse` rejects warns + * without echoing it, since these variables carry credentials. + */ const readOrWarn = ( - config: Config.Config, name: string, - warning: (raw: string) => string, + parse: (raw: string) => Option.Option, + warning: string, ): Config.Config> => - config.pipe( - Config.map((value): Setting => ({ value })), - Config.orElse(() => - Config.String(name).pipe( - Config.option, - Config.map((raw): Setting => { - const value = blankAsUnset(Option.getOrUndefined(raw)); - return value === undefined - ? { value: undefined } - : { value: undefined, warning: warning(value) }; - }), - ), - ), + Config.String(name).pipe( + Config.option, + Config.map((option): Setting => { + const raw = blankAsUnset(Option.getOrUndefined(option)); + if (raw === undefined) { + return { value: undefined }; + } + return Option.match(parse(raw), { + onNone: () => ({ value: undefined, warning }), + onSome: (value) => ({ value }), + }); + }), ); -const endpoint = (name: string) => - readOrWarn( - Config.URL(name), - name, - // The value is left out because an endpoint can carry an API key. - () => `${name} is not a URL and was ignored`, +const parseHttpUrl = (raw: string) => + Option.liftThrowable((value: string) => new URL(value))(raw).pipe( + Option.filter((url) => url.protocol === "http:" || url.protocol === "https:"), ); +const NOT_EXPORTED = "so the signals it configures are not exported"; + +const endpoint = (name: string) => + readOrWarn(name, parseHttpUrl, `${name} is not an http or https URL, ${NOT_EXPORTED}`); + +// The specification reads enum values case-insensitively. const protocol = (name: string) => readOrWarn( - Config.schema(OtlpProtocol, name), name, - (raw) => - `${name}=${raw} is not http/protobuf or http/json, so the signals it configures are not exported`, + (raw) => Schema.decodeUnknownOption(OtlpProtocol)(raw.toLowerCase()), + `${name} is not http/protobuf or http/json, ${NOT_EXPORTED}`, ); const headers = (name: string) => readOrWarn( - // The schema Effect's OTLP exporters read these variables with. - Config.Record(Schema.String, Schema.StringFromUriComponent, name), name, - // The value is left out because headers carry credentials. - () => - `${name} has a value that is not percent-encoded, so the signals it configures are not exported`, + Schema.decodeUnknownOption(OtlpHeadersFromString), + `${name} is not a list of key=value pairs with percent-encoded values, ${NOT_EXPORTED}`, ); interface Settings { @@ -212,14 +213,16 @@ interface ResolvedSignal { } /** - * A signal whose protocol or headers do not read is not exported rather than - * sent in a format or without the credentials its collector expects. + * A signal whose endpoint, protocol, or headers do not read is not exported + * rather than sent somewhere, in a format, or without the credentials its + * collector expects. */ const signal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => { const ownEndpoint = isClaimed(own.endpoint); const endpoint = ownEndpoint ? own.endpoint : generic.endpoint; if (endpoint.value === undefined) { - return { signal: OtelSignal.Unset(), used: [endpoint] }; + const signal = endpoint.warning === undefined ? OtelSignal.Unset() : OtelSignal.Off(); + return { signal, used: [endpoint] }; } const protocol = claimed(own.protocol, generic.protocol); const headers = claimed(own.headers, generic.headers); From 5abd6a68e4d2fcec157fe0495a8c3838110edc4b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 25 Sep 2026 08:34:18 -0400 Subject: [PATCH 7/8] docs(observability): state per-signal endpoint precedence Signed-off-by: Yordis Prieto --- docs/operations/observability.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 5631084f8bbd..b028c1d67018 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -554,9 +554,9 @@ OTLP export: The server also reads the standard `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` and generic `OTEL_EXPORTER_OTLP_ENDPOINT` (with `/v1/traces`, `/v1/metrics`, or `/v1/logs` appended), for a -collector expecting those instead. A non-blank `T3CODE_OTLP_*_URL` wins over either, and a blank one -counts as unset. A signal with an OTEL endpoint takes its headers from `OTEL_EXPORTER_OTLP_HEADERS` -and its protocol from `OTEL_EXPORTER_OTLP_PROTOCOL` (default `http/protobuf`, read +collector expecting those instead. A non-blank `T3CODE_OTLP_*_URL` wins over either, and a +per-signal endpoint wins over the generic one for its signal. A blank value counts as unset. A +signal with an OTEL endpoint takes its headers from `OTEL_EXPORTER_OTLP_HEADERS` and its protocol from `OTEL_EXPORTER_OTLP_PROTOCOL` (default `http/protobuf`, read case-insensitively), and a per-signal `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` or `_PROTOCOL` wins over the generic one for its signal. `T3CODE_OTLP_HEADERS` and `T3CODE_OTLP_PROTOCOL` never apply to it. An endpoint that is not an `http` or `https` URL, a From b2eb64a444810a6af9e24d42d6127a8232b0f452 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 25 Sep 2026 08:35:06 -0400 Subject: [PATCH 8/8] docs(observability): rewrap the OTEL endpoint paragraph Signed-off-by: Yordis Prieto --- docs/operations/observability.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/operations/observability.md b/docs/operations/observability.md index b028c1d67018..0bdb0eb0c0f8 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -556,13 +556,13 @@ The server also reads the standard `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_END `OTEL_EXPORTER_OTLP_ENDPOINT` (with `/v1/traces`, `/v1/metrics`, or `/v1/logs` appended), for a collector expecting those instead. A non-blank `T3CODE_OTLP_*_URL` wins over either, and a per-signal endpoint wins over the generic one for its signal. A blank value counts as unset. A -signal with an OTEL endpoint takes its headers from `OTEL_EXPORTER_OTLP_HEADERS` and its protocol from `OTEL_EXPORTER_OTLP_PROTOCOL` (default `http/protobuf`, read -case-insensitively), and a per-signal `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` or -`_PROTOCOL` wins over the generic one for its signal. `T3CODE_OTLP_HEADERS` and -`T3CODE_OTLP_PROTOCOL` never apply to it. An endpoint that is not an `http` or `https` URL, a -protocol other than `http/protobuf` or `http/json` such as `grpc`, or headers that are not -`key=value` pairs with percent-encoded values turn that signal's export off with a startup warning, -rather than sending it to the Settings endpoint. +signal with an OTEL endpoint takes its headers from `OTEL_EXPORTER_OTLP_HEADERS` and its protocol +from `OTEL_EXPORTER_OTLP_PROTOCOL` (default `http/protobuf`, read case-insensitively), and a +per-signal `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` or `_PROTOCOL` wins over the generic +one for its signal. `T3CODE_OTLP_HEADERS` and `T3CODE_OTLP_PROTOCOL` never apply to it. An endpoint +that is not an `http` or `https` URL, a protocol other than `http/protobuf` or `http/json` such as +`grpc`, or headers that are not `key=value` pairs with percent-encoded values turn that signal's +export off with a startup warning, rather than sending it to the Settings endpoint. If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay on stdout only.