authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-09-26 18:05:09-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-26 15:05:09-07:00
log74ee5153e05eb183dabec413ff9025d83d2058e4
tree9e2181eca5bf8f8d7b7e3b5ff73344f4d84b8ea8
parent679c34c096193446ceed62e633387d91fe2cfff8
signature Signed by PGP key B5690EEEBB952194

feat(observability): honor OTEL_*_EXPORTER=none per signal (#13736)

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>

4 files changed, 155 insertions(+), 9 deletions(-)

apps/desktop/src/backend/DesktopBackendConfiguration.ts+3
......@@ -118,6 +118,9 @@ const WSL_FORWARDED_ENV_NAMES = [
118118 "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
119119 "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL",
120120 "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL",
121 "OTEL_TRACES_EXPORTER",
122 "OTEL_METRICS_EXPORTER",
123 "OTEL_LOGS_EXPORTER",
121124] as const;
122125
123126const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
docs/operations/observability.md+5
......@@ -631,6 +631,11 @@ machine that sets `OTEL_SDK_DISABLED` for everything else. It accepts the usual
631631OpenTelemetry specification and only `true` disables export, so `OTEL_SDK_DISABLED=1` does not.
632632Values are case-insensitive and trimmed. An unrecognized value is ignored with a startup warning.
633633
634`OTEL_TRACES_EXPORTER`, `OTEL_METRICS_EXPORTER`, or `OTEL_LOGS_EXPORTER` set to `none` turns off
635just that signal, overriding an OTEL endpoint and the Settings endpoint. A `T3CODE_OTLP_*_URL` still
636wins for its signal. `otlp` is the default, and any other exporter name, such as `console` or
637`prometheus`, is ignored with a startup warning.
638
634639### What Is Instrumented Today
635640
636641Current high-value span and metric boundaries include:
packages/shared/src/otelEnvironment.test.ts+86
......@@ -204,6 +204,73 @@ describe("OtelEnvironment", () => {
204204 logs: "Unset",
205205 warnings: [T3_OFF],
206206 },
207 {
208 name: "an exporter of none turns off only its signal",
209 env: {
210 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318",
211 OTEL_LOGS_EXPORTER: "none",
212 },
213 traces: "https://collector:4318/v1/traces",
214 metrics: "https://collector:4318/v1/metrics",
215 logs: "Off",
216 warnings: [],
217 },
218 {
219 name: "an exporter of none turns its signal off with no endpoint named",
220 env: { OTEL_METRICS_EXPORTER: " NONE " },
221 traces: "Unset",
222 metrics: "Off",
223 logs: "Unset",
224 warnings: [],
225 },
226 {
227 name: "an exporter of none leaves its signal's endpoint unread",
228 env: { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "not-a-url", OTEL_TRACES_EXPORTER: "none" },
229 traces: "Off",
230 metrics: "Unset",
231 logs: "Unset",
232 warnings: [],
233 },
234 {
235 name: "an exporter of otlp or blank is the default",
236 env: {
237 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318",
238 OTEL_TRACES_EXPORTER: "OTLP",
239 OTEL_LOGS_EXPORTER: " ",
240 },
241 traces: "https://collector:4318/v1/traces",
242 metrics: "https://collector:4318/v1/metrics",
243 logs: "https://collector:4318/v1/logs",
244 warnings: [],
245 },
246 {
247 name: "an exporter T3 Code does not have is ignored with a warning",
248 env: {
249 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318",
250 OTEL_METRICS_EXPORTER: "prometheus",
251 },
252 traces: "https://collector:4318/v1/traces",
253 metrics: "https://collector:4318/v1/metrics",
254 logs: "https://collector:4318/v1/logs",
255 warnings: [
256 "OTEL_METRICS_EXPORTER names prometheus, which T3 Code does not export to, so it was ignored",
257 ],
258 },
259 {
260 name: "a list honors none and otlp and names what it ignored",
261 env: {
262 OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318",
263 OTEL_TRACES_EXPORTER: "console,none",
264 OTEL_LOGS_EXPORTER: "otlp,console,otlpp",
265 },
266 traces: "Off",
267 metrics: "https://collector:4318/v1/metrics",
268 logs: "https://collector:4318/v1/logs",
269 warnings: [
270 "OTEL_TRACES_EXPORTER names console, which T3 Code does not export to, so it was ignored",
271 "OTEL_LOGS_EXPORTER names console, otlpp, which T3 Code does not export to, so they were ignored",
272 ],
273 },
207274 ])("$name", ({ env, traces, metrics, logs, warnings }) =>
208275 Effect.gen(function* () {
209276 const resolved = yield* load(env);
......@@ -396,6 +463,25 @@ describe("OtelEnvironment", () => {
396463 });
397464 });
398465
466 it.effect("an exporter of none keeps the Settings endpoint from re-enabling its signal", () =>
467 Effect.gen(function* () {
468 const otel = yield* load({ OTEL_LOGS_EXPORTER: "none" });
469 const t3 = {
470 url: undefined,
471 export: { protocol: "http/json", headers: undefined, exportIntervalMs: 10_000 },
472 } as const;
473 assert.strictEqual(
474 OtelEnvironment.resolveSignalEndpoint(otel, "logs", t3, "http://settings:4318/v1/logs"),
475 undefined,
476 );
477 assert.strictEqual(
478 OtelEnvironment.resolveSignalEndpoint(otel, "traces", t3, "http://settings:4318/v1/traces")
479 ?.url,
480 "http://settings:4318/v1/traces",
481 );
482 }),
483 );
484
399485 describe("layerResourceAttributes", () => {
400486 it.effect.each([
401487 { name: "a list that does not decode", raw: "team=%zz", attributes: [] },
packages/shared/src/otelEnvironment.ts+61-9
......@@ -1,5 +1,5 @@
11/**
2 * otelEnvironment: the OpenTelemetry kill switch and endpoint variables,
2 * otelEnvironment: the OpenTelemetry kill switch, exporter, and endpoint variables,
33 * shared by the server and the desktop main process so both agree on what
44 * turns export off and where it goes.
55 *
......@@ -23,8 +23,9 @@ type OtlpSignalName = "TRACES" | "METRICS" | "LOGS";
2323
2424/**
2525 * What the OTEL variables say about one signal. `Off` is a signal they
26 * claimed with an endpoint, protocol, or headers that do not read, so it is
27 * exported nowhere rather than to the bootstrap or Settings collector.
26 * claimed with an endpoint, protocol, or headers that do not read, or turned
27 * off with `OTEL_<SIGNAL>_EXPORTER=none`, so it is exported nowhere rather
28 * than to the bootstrap or Settings collector.
2829 */
2930export type OtelSignal = Data.TaggedEnum<{
3031 Unset: {};
......@@ -179,6 +180,38 @@ const headers = (name: string) =>
179180 `${name} is not a list of key=value pairs with percent-encoded values, ${NOT_EXPORTED}`,
180181 );
181182
183type Exporter = "otlp" | "none";
184
185const EXPORTERS: ReadonlySet<string> = new Set<Exporter>(["otlp", "none"]);
186
187const isExporter = (entry: string): entry is Exporter => EXPORTERS.has(entry);
188
189/**
190 * `OTEL_<SIGNAL>_EXPORTER`, a case-insensitive list whose default is `otlp`.
191 * Entries T3 Code has no exporter for are named in a warning and dropped, and
192 * a list left with nothing to honor reads as unset, as the specification asks
193 * of any enum value an implementation does not recognize.
194 */
195const exporter = (name: string): Config.Config<Setting<Exporter>> =>
196 Config.String(name).pipe(
197 Config.option,
198 Config.map((option): Setting<Exporter> => {
199 const entries = (Option.getOrUndefined(option) ?? "")
200 .split(",")
201 .map((entry) => entry.trim().toLowerCase())
202 .filter((entry) => entry !== "");
203 const ignored = [...new Set(entries.filter((entry) => !isExporter(entry)))];
204 const known = new Set(entries.filter(isExporter));
205 const value = known.has("otlp") ? "otlp" : known.has("none") ? "none" : undefined;
206 return ignored.length === 0
207 ? { value }
208 : {
209 value,
210 warning: `${name} names ${ignored.join(", ")}, which T3 Code does not export to, so ${ignored.length === 1 ? "it was" : "they were"} ignored`,
211 };
212 }),
213 );
214
182215interface Settings {
183216 readonly endpoint: Setting<URL>;
184217 readonly protocol: Setting<OtlpProtocol>;
......@@ -215,9 +248,23 @@ interface ResolvedSignal {
215248/**
216249 * A signal whose endpoint, protocol, or headers do not read is not exported
217250 * rather than sent somewhere, in a format, or without the credentials its
218 * collector expects.
251 * collector expects. `none` turns the signal off before any of those are read,
252 * whether or not an endpoint was named.
219253 */
220const signal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => {
254const signal = (
255 name: OtlpSignalName,
256 exporter: Setting<Exporter>,
257 own: Settings,
258 generic: Settings,
259): ResolvedSignal => {
260 if (exporter.value === "none") {
261 return { signal: OtelSignal.Off(), used: [exporter] };
262 }
263 const resolved = endpointSignal(name, own, generic);
264 return { signal: resolved.signal, used: [exporter, ...resolved.used] };
265};
266
267const endpointSignal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => {
221268 const ownEndpoint = isClaimed(own.endpoint);
222269 const endpoint = ownEndpoint ? own.endpoint : generic.endpoint;
223270 if (endpoint.value === undefined) {
......@@ -263,16 +310,21 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({
263310 traces: settings("OTEL_EXPORTER_OTLP_TRACES_"),
264311 metrics: settings("OTEL_EXPORTER_OTLP_METRICS_"),
265312 logs: settings("OTEL_EXPORTER_OTLP_LOGS_"),
313 exporters: Config.all({
314 traces: exporter("OTEL_TRACES_EXPORTER"),
315 metrics: exporter("OTEL_METRICS_EXPORTER"),
316 logs: exporter("OTEL_LOGS_EXPORTER"),
317 }),
266318}).pipe(
267 Effect.map(({ t3, spec, resource, generic, ...own }) => {
319 Effect.map(({ t3, spec, resource, generic, exporters, ...own }) => {
268320 const disabled = t3.value ?? spec.value ?? false;
269321 // The kill switch wins outright, so the signals say nothing once it is set.
270322 const signals = disabled
271323 ? undefined
272324 : {
273 traces: signal("TRACES", own.traces, generic),
274 metrics: signal("METRICS", own.metrics, generic),
275 logs: signal("LOGS", own.logs, generic),
325 traces: signal("TRACES", exporters.traces, own.traces, generic),
326 metrics: signal("METRICS", exporters.metrics, own.metrics, generic),
327 logs: signal("LOGS", exporters.logs, own.logs, generic),
276328 };
277329 // A generic variable read by several signals warns once.
278330 const used = new Set(