From 74ee5153e05eb183dabec413ff9025d83d2058e4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 26 Sep 2026 18:05:09 -0400 Subject: [PATCH] feat(observability): honor OTEL_*_EXPORTER=none per signal (#13736) Signed-off-by: Yordis Prieto --- .../backend/DesktopBackendConfiguration.ts | 3 + docs/operations/observability.md | 5 ++ packages/shared/src/otelEnvironment.test.ts | 86 +++++++++++++++++++ packages/shared/src/otelEnvironment.ts | 70 +++++++++++++-- 4 files changed, 155 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index b3bec8830ec614498690fa36f49342c474d881da..c2012299802d7c7993d52206b67e1ed82bed9bb7 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -118,6 +118,9 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", + "OTEL_TRACES_EXPORTER", + "OTEL_METRICS_EXPORTER", + "OTEL_LOGS_EXPORTER", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; diff --git a/docs/operations/observability.md b/docs/operations/observability.md index c6537f6eac71f377cad767237183ae6cb64c7e25..910bd018bcc766f70a5ae874c07eb25dda90fe7d 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -631,6 +631,11 @@ machine that sets `OTEL_SDK_DISABLED` for everything else. It accepts the usual OpenTelemetry specification and only `true` disables export, so `OTEL_SDK_DISABLED=1` does not. Values are case-insensitive and trimmed. An unrecognized value is ignored with a startup warning. +`OTEL_TRACES_EXPORTER`, `OTEL_METRICS_EXPORTER`, or `OTEL_LOGS_EXPORTER` set to `none` turns off +just that signal, overriding an OTEL endpoint and the Settings endpoint. A `T3CODE_OTLP_*_URL` still +wins for its signal. `otlp` is the default, and any other exporter name, such as `console` or +`prometheus`, is ignored with a startup warning. + ### What Is Instrumented Today Current high-value span and metric boundaries include: diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts index cd4e683faa6b10b20c6546e2bf067c1dbf33c6aa..e436c64c636028640ee764760275dbb558337ec0 100644 --- a/packages/shared/src/otelEnvironment.test.ts +++ b/packages/shared/src/otelEnvironment.test.ts @@ -204,6 +204,73 @@ describe("OtelEnvironment", () => { logs: "Unset", warnings: [T3_OFF], }, + { + name: "an exporter of none turns off only its signal", + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318", + OTEL_LOGS_EXPORTER: "none", + }, + traces: "https://collector:4318/v1/traces", + metrics: "https://collector:4318/v1/metrics", + logs: "Off", + warnings: [], + }, + { + name: "an exporter of none turns its signal off with no endpoint named", + env: { OTEL_METRICS_EXPORTER: " NONE " }, + traces: "Unset", + metrics: "Off", + logs: "Unset", + warnings: [], + }, + { + name: "an exporter of none leaves its signal's endpoint unread", + env: { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "not-a-url", OTEL_TRACES_EXPORTER: "none" }, + traces: "Off", + metrics: "Unset", + logs: "Unset", + warnings: [], + }, + { + name: "an exporter of otlp or blank is the default", + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318", + OTEL_TRACES_EXPORTER: "OTLP", + OTEL_LOGS_EXPORTER: " ", + }, + traces: "https://collector:4318/v1/traces", + metrics: "https://collector:4318/v1/metrics", + logs: "https://collector:4318/v1/logs", + warnings: [], + }, + { + name: "an exporter T3 Code does not have is ignored with a warning", + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318", + OTEL_METRICS_EXPORTER: "prometheus", + }, + traces: "https://collector:4318/v1/traces", + metrics: "https://collector:4318/v1/metrics", + logs: "https://collector:4318/v1/logs", + warnings: [ + "OTEL_METRICS_EXPORTER names prometheus, which T3 Code does not export to, so it was ignored", + ], + }, + { + name: "a list honors none and otlp and names what it ignored", + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318", + OTEL_TRACES_EXPORTER: "console,none", + OTEL_LOGS_EXPORTER: "otlp,console,otlpp", + }, + traces: "Off", + metrics: "https://collector:4318/v1/metrics", + logs: "https://collector:4318/v1/logs", + warnings: [ + "OTEL_TRACES_EXPORTER names console, which T3 Code does not export to, so it was ignored", + "OTEL_LOGS_EXPORTER names console, otlpp, which T3 Code does not export to, so they were ignored", + ], + }, ])("$name", ({ env, traces, metrics, logs, warnings }) => Effect.gen(function* () { const resolved = yield* load(env); @@ -396,6 +463,25 @@ describe("OtelEnvironment", () => { }); }); + it.effect("an exporter of none keeps the Settings endpoint from re-enabling its signal", () => + Effect.gen(function* () { + const otel = yield* load({ OTEL_LOGS_EXPORTER: "none" }); + const t3 = { + url: undefined, + export: { protocol: "http/json", headers: undefined, exportIntervalMs: 10_000 }, + } as const; + assert.strictEqual( + OtelEnvironment.resolveSignalEndpoint(otel, "logs", t3, "http://settings:4318/v1/logs"), + undefined, + ); + assert.strictEqual( + OtelEnvironment.resolveSignalEndpoint(otel, "traces", t3, "http://settings:4318/v1/traces") + ?.url, + "http://settings:4318/v1/traces", + ); + }), + ); + 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 05a787dcfedea7b9aa257a5a02d5ebde618f5d86..c2496bd28a527a6ba08b8a1287a666e464b24acd 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -1,5 +1,5 @@ /** - * otelEnvironment: the OpenTelemetry kill switch and endpoint variables, + * otelEnvironment: the OpenTelemetry kill switch, exporter, and endpoint variables, * shared by the server and the desktop main process so both agree on what * turns export off and where it goes. * @@ -23,8 +23,9 @@ type OtlpSignalName = "TRACES" | "METRICS" | "LOGS"; /** * What the OTEL variables say about one signal. `Off` is a signal they - * claimed with an endpoint, protocol, or headers that do not read, so it is - * exported nowhere rather than to the bootstrap or Settings collector. + * claimed with an endpoint, protocol, or headers that do not read, or turned + * off with `OTEL__EXPORTER=none`, so it is exported nowhere rather + * than to the bootstrap or Settings collector. */ export type OtelSignal = Data.TaggedEnum<{ Unset: {}; @@ -179,6 +180,38 @@ const headers = (name: string) => `${name} is not a list of key=value pairs with percent-encoded values, ${NOT_EXPORTED}`, ); +type Exporter = "otlp" | "none"; + +const EXPORTERS: ReadonlySet = new Set(["otlp", "none"]); + +const isExporter = (entry: string): entry is Exporter => EXPORTERS.has(entry); + +/** + * `OTEL__EXPORTER`, a case-insensitive list whose default is `otlp`. + * Entries T3 Code has no exporter for are named in a warning and dropped, and + * a list left with nothing to honor reads as unset, as the specification asks + * of any enum value an implementation does not recognize. + */ +const exporter = (name: string): Config.Config> => + Config.String(name).pipe( + Config.option, + Config.map((option): Setting => { + const entries = (Option.getOrUndefined(option) ?? "") + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry !== ""); + const ignored = [...new Set(entries.filter((entry) => !isExporter(entry)))]; + const known = new Set(entries.filter(isExporter)); + const value = known.has("otlp") ? "otlp" : known.has("none") ? "none" : undefined; + return ignored.length === 0 + ? { value } + : { + value, + warning: `${name} names ${ignored.join(", ")}, which T3 Code does not export to, so ${ignored.length === 1 ? "it was" : "they were"} ignored`, + }; + }), + ); + interface Settings { readonly endpoint: Setting; readonly protocol: Setting; @@ -215,9 +248,23 @@ interface ResolvedSignal { /** * 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. + * collector expects. `none` turns the signal off before any of those are read, + * whether or not an endpoint was named. */ -const signal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => { +const signal = ( + name: OtlpSignalName, + exporter: Setting, + own: Settings, + generic: Settings, +): ResolvedSignal => { + if (exporter.value === "none") { + return { signal: OtelSignal.Off(), used: [exporter] }; + } + const resolved = endpointSignal(name, own, generic); + return { signal: resolved.signal, used: [exporter, ...resolved.used] }; +}; + +const endpointSignal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => { const ownEndpoint = isClaimed(own.endpoint); const endpoint = ownEndpoint ? own.endpoint : generic.endpoint; if (endpoint.value === undefined) { @@ -263,16 +310,21 @@ export const load: Effect.Effect = Config.all({ traces: settings("OTEL_EXPORTER_OTLP_TRACES_"), metrics: settings("OTEL_EXPORTER_OTLP_METRICS_"), logs: settings("OTEL_EXPORTER_OTLP_LOGS_"), + exporters: Config.all({ + traces: exporter("OTEL_TRACES_EXPORTER"), + metrics: exporter("OTEL_METRICS_EXPORTER"), + logs: exporter("OTEL_LOGS_EXPORTER"), + }), }).pipe( - Effect.map(({ t3, spec, resource, generic, ...own }) => { + Effect.map(({ t3, spec, resource, generic, exporters, ...own }) => { const disabled = t3.value ?? spec.value ?? false; // The kill switch wins outright, so the signals say nothing once it is set. const signals = disabled ? undefined : { - traces: signal("TRACES", own.traces, generic), - metrics: signal("METRICS", own.metrics, generic), - logs: signal("LOGS", own.logs, generic), + traces: signal("TRACES", exporters.traces, own.traces, generic), + metrics: signal("METRICS", exporters.metrics, own.metrics, generic), + logs: signal("LOGS", exporters.logs, own.logs, generic), }; // A generic variable read by several signals warns once. const used = new Set( -- 2.54.0