authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-23 22:56:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-23 22:56:15-07:00
loge67abcf798f8c4d8458755e3b4dde02c2c1f628b
treec2009ddf7ba9f14f3033def3d10bed2b92ba7a47
parent9383f4ad71285b9f9a6a9b65f2f961ea2cf4308f
signature Signed by PGP key B5690EEEBB952194

feat(observability): honor the OpenTelemetry kill switch (#13355)

Co-authored-by: Yordis Prieto <yordis.prieto@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>

17 files changed, 472 insertions(+), 12 deletions(-)

apps/desktop/src/app/DesktopObservability.test.ts+64
......@@ -1,6 +1,7 @@
11import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import { assert, describe, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
45import * as Effect from "effect/Effect";
56import * as FileSystem from "effect/FileSystem";
67import * as Layer from "effect/Layer";
......@@ -427,6 +428,36 @@ describe("DesktopObservability", () => {
427428 );
428429 });
429430
431 it.effect("exports kill switch warnings through the configured logger", () => {
432 const requests: Array<ExportedRequest> = [];
433 return Effect.gen(function* () {
434 const fileSystem = yield* FileSystem.FileSystem;
435 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
436 prefix: "t3-desktop-observability-test-",
437 });
438 const environmentLayer = makeEnvironmentLayer(baseDir, true, {
439 T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs",
440 });
441
442 yield* Effect.scoped(
443 Effect.void.pipe(
444 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
445 ),
446 );
447
448 assert.include(requests[0]?.body ?? "", "OTEL_SDK_DISABLED=1 was read as false");
449 }).pipe(
450 Effect.scoped,
451 Effect.provide(
452 Layer.mergeAll(
453 NodeServices.layer,
454 collectorLayer(requests),
455 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "1" } })),
456 ),
457 ),
458 );
459 });
460
430461 it.effect("reads every signal endpoint from Settings when the environment names none", () => {
431462 const requests: Array<ExportedRequest> = [];
432463 return Effect.gen(function* () {
......@@ -491,4 +522,37 @@ describe("DesktopObservability", () => {
491522 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
492523 );
493524 });
525
526 it.effect("stops every export when the OpenTelemetry SDK is disabled", () => {
527 const requests: Array<ExportedRequest> = [];
528 return Effect.gen(function* () {
529 const fileSystem = yield* FileSystem.FileSystem;
530 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
531 prefix: "t3-desktop-observability-test-",
532 });
533 const environmentLayer = makeEnvironmentLayer(baseDir);
534 yield* writeObservabilitySettings(environmentLayer, {
535 otlpTracesUrl: "https://settings.example.com/v1/traces",
536 otlpLogsUrl: "https://settings.example.com/v1/logs",
537 });
538
539 yield* Effect.scoped(
540 Effect.logInfo("desktop log stays local when disabled").pipe(
541 Effect.withSpan("desktop-disabled-test"),
542 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
543 ),
544 );
545
546 assert.lengthOf(requests, 0);
547 }).pipe(
548 Effect.scoped,
549 Effect.provide(
550 Layer.mergeAll(
551 NodeServices.layer,
552 collectorLayer(requests),
553 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "true" } })),
554 ),
555 ),
556 );
557 });
494558});
apps/desktop/src/app/DesktopObservability.ts+13-1
......@@ -4,6 +4,7 @@ import {
44 makeTraceSink,
55 otlpSerializationLayer,
66} from "@t3tools/shared/observability";
7import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
78import {
89 parsePersistedServerObservabilitySettings,
910 type PersistedServerObservabilitySettings,
......@@ -353,12 +354,18 @@ const readPersistedObservabilitySettings: Effect.Effect<
353354 * resolve traces against one revision of the file and logs against another.
354355 */
355356const resolveOtlpEndpoints = Effect.gen(function* () {
357 const otel = yield* OtelEnvironment.load;
358 if (otel.disabled) {
359 return { traces: undefined, metrics: undefined, logs: undefined, warnings: otel.warnings };
360 }
361
356362 const environment = yield* DesktopEnvironment.DesktopEnvironment;
357363 const persisted = yield* readPersistedObservabilitySettings;
358364 return {
359365 traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl,
360366 metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl,
361367 logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl,
368 warnings: otel.warnings,
362369 };
363370});
364371
......@@ -671,7 +678,12 @@ const telemetryLayer = Layer.unwrap(
671678 // resource,
672679 // }).pipe(Layer.provide(serializationLayer));
673680
674 return Layer.mergeAll(loggerLayer, tracerLayer);
681 // Logged once the loggers above are installed, so the warnings use them.
682 const otelWarningsLayer = Layer.effectDiscard(
683 Effect.forEach(endpoints.warnings, (warning) => Effect.logWarning(warning)),
684 );
685
686 return otelWarningsLayer.pipe(Layer.provideMerge(Layer.mergeAll(loggerLayer, tracerLayer)));
675687 }),
676688);
677689
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+43
......@@ -946,6 +946,49 @@ describe("DesktopBackendConfiguration", () => {
946946 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
947947 );
948948
949 it.effect("resolveWsl carries the kill switch into the distro", () =>
950 Effect.gen(function* () {
951 const fileSystem = yield* FileSystem.FileSystem;
952 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
953 prefix: "t3-desktop-backend-config-test-",
954 });
955
956 const previousWslEnv = process.env.WSLENV;
957 const previousDisabled = process.env.OTEL_SDK_DISABLED;
958 try {
959 delete process.env.WSLENV;
960 process.env.OTEL_SDK_DISABLED = "true";
961
962 yield* Effect.gen(function* () {
963 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
964 const config = yield* configuration.resolveWsl({ port: 5050, distro: null });
965
966 assert.equal(config.env.OTEL_SDK_DISABLED, "true");
967 assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED");
968 }).pipe(
969 Effect.provide(
970 DesktopBackendConfiguration.layer.pipe(
971 Layer.provideMerge(serverExposureLayer),
972 Layer.provideMerge(DesktopAppSettings.layerTest()),
973 Layer.provideMerge(DesktopWslServerTree.layerTest()),
974 Layer.provideMerge(
975 DesktopWslEnvironment.layerTest({
976 isAvailable: true,
977 windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"),
978 getDistroIp: () => Option.some("172.27.0.99"),
979 }),
980 ),
981 Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
982 ),
983 ),
984 );
985 } finally {
986 restoreEnv("WSLENV", previousWslEnv);
987 restoreEnv("OTEL_SDK_DISABLED", previousDisabled);
988 }
989 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
990 );
991
949992 it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () =>
950993 Effect.gen(function* () {
951994 const fileSystem = yield* FileSystem.FileSystem;
apps/desktop/src/backend/DesktopBackendConfiguration.ts+3
......@@ -96,6 +96,9 @@ const DESKTOP_BACKEND_ENV_NAMES = [
9696const WSL_FORWARDED_ENV_NAMES = [
9797 "OPENAI_API_KEY",
9898 "ANTHROPIC_API_KEY",
99 // Otherwise the WSL server keeps exporting to endpoints from the bootstrap.
100 "T3CODE_OTEL_SDK_DISABLED",
101 "OTEL_SDK_DISABLED",
99102 "T3CODE_OTLP_HEADERS",
100103 "T3CODE_OTLP_PROTOCOL",
101104] as const;
apps/server/src/bin.test.ts+2
......@@ -16,6 +16,7 @@ import {
1616import * as NetService from "@t3tools/shared/Net";
1717import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
1818import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
19import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
1920import { assert, it } from "@effect/vitest";
2021import * as Effect from "effect/Effect";
2122import * as DateTime from "effect/DateTime";
......@@ -106,6 +107,7 @@ const makeCliTestServerConfig = (baseDir: string) =>
106107 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
107108 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
108109 otlpServiceName: "t3-server",
110 otelEnvironment: OtelEnvironment.none,
109111 mode: "web",
110112 port: 0,
111113 host: "127.0.0.1",
apps/server/src/cli/config.test.ts+105
......@@ -18,6 +18,7 @@ import {
1818} from "@t3tools/contracts";
1919import * as NetService from "@t3tools/shared/Net";
2020import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
21import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
2122import * as NodeServices from "@effect/platform-node/NodeServices";
2223import { deriveServerPaths } from "../config.ts";
2324import { resolveServerConfig } from "./config.ts";
......@@ -56,6 +57,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
5657 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
5758 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
5859 otlpServiceName: "t3-server",
60 otelEnvironment: OtelEnvironment.none,
5961 devAllowedOrigins: [],
6062 } as const;
6163
......@@ -666,6 +668,109 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
666668 }),
667669 );
668670
671 it.effect("zeroes an endpoint stored in Settings when the SDK is disabled", () =>
672 Effect.gen(function* () {
673 const fs = yield* FileSystem.FileSystem;
674 const path = yield* Path.Path;
675 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-otel-off-" });
676 const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined);
677 yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true });
678 yield* fs.writeFileString(
679 derivedPaths.settingsPath,
680 // @effect-diagnostics-next-line preferSchemaOverJson:off
681 `${JSON.stringify({
682 observability: {
683 otlpTracesUrl: "http://localhost:4318/v1/traces",
684 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
685 otlpLogsUrl: "http://localhost:4318/v1/logs",
686 },
687 })}\n`,
688 );
689
690 const resolved = yield* resolveServerConfig(
691 {
692 mode: Option.some("desktop"),
693 port: Option.some(4888),
694 host: Option.none(),
695 baseDir: Option.some(baseDir),
696 cwd: Option.none(),
697 devUrl: Option.none(),
698 noBrowser: Option.none(),
699 bootstrapFd: Option.none(),
700 autoBootstrapProjectFromCwd: Option.none(),
701 logWebSocketEvents: Option.none(),
702 tailscaleServeEnabled: Option.none(),
703 tailscaleServePort: Option.none(),
704 },
705 Option.none(),
706 ).pipe(
707 Effect.provide(
708 Layer.mergeAll(
709 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "true" } })),
710 NetService.layer,
711 ),
712 ),
713 );
714
715 // The switch beats every source, including an endpoint stored in Settings.
716 expect(resolved.otlpTracesUrl).toBeUndefined();
717 expect(resolved.otlpMetricsUrl).toBeUndefined();
718 expect(resolved.otlpLogsUrl).toBeUndefined();
719 expect(resolved.otelEnvironment.disabled).toBe(true);
720 }),
721 );
722
723 it.effect("lets T3CODE_OTEL_SDK_DISABLED=false override an ambient OTEL_SDK_DISABLED=true", () =>
724 Effect.gen(function* () {
725 const fs = yield* FileSystem.FileSystem;
726 const path = yield* Path.Path;
727 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-otel-on-" });
728 const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined);
729 yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true });
730 yield* fs.writeFileString(
731 derivedPaths.settingsPath,
732 // @effect-diagnostics-next-line preferSchemaOverJson:off
733 `${JSON.stringify({
734 observability: {
735 otlpTracesUrl: "http://localhost:4318/v1/traces",
736 },
737 })}\n`,
738 );
739
740 const resolved = yield* resolveServerConfig(
741 {
742 mode: Option.some("desktop"),
743 port: Option.some(4888),
744 host: Option.none(),
745 baseDir: Option.some(baseDir),
746 cwd: Option.none(),
747 devUrl: Option.none(),
748 noBrowser: Option.none(),
749 bootstrapFd: Option.none(),
750 autoBootstrapProjectFromCwd: Option.none(),
751 logWebSocketEvents: Option.none(),
752 tailscaleServeEnabled: Option.none(),
753 tailscaleServePort: Option.none(),
754 },
755 Option.none(),
756 ).pipe(
757 Effect.provide(
758 Layer.mergeAll(
759 ConfigProvider.layer(
760 ConfigProvider.fromEnv({
761 env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "true" },
762 }),
763 ),
764 NetService.layer,
765 ),
766 ),
767 );
768
769 expect(resolved.otelEnvironment.disabled).toBe(false);
770 expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
771 }),
772 );
773
669774 it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () =>
670775 Effect.gen(function* () {
671776 const { join } = yield* Path.Path;
apps/server/src/cli/config.ts+17-10
......@@ -4,6 +4,7 @@ import {
44 OtlpProtocol,
55 type SignalExport,
66} from "@t3tools/shared/observability";
7import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
78import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
89import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";
910import * as Config from "effect/Config";
......@@ -386,6 +387,8 @@ export const resolveServerConfig = (
386387 );
387388 const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel);
388389
390 const otel = yield* OtelEnvironment.load;
391
389392 // T3 Code's own OTLP variables name no signal, so the one answer they give
390393 // is the answer for all three.
391394 const signalExport: SignalExport = {
......@@ -401,20 +404,24 @@ export const resolveServerConfig = (
401404 traceBatchWindowMs: env.traceBatchWindowMs,
402405 traceMaxBytes: env.traceMaxBytes,
403406 traceMaxFiles: env.traceMaxFiles,
404 otlpTracesUrl:
405 env.otlpTracesUrl ??
406 bootstrap?.otlpTracesUrl ??
407 persistedObservabilitySettings.otlpTracesUrl,
408 otlpMetricsUrl:
409 env.otlpMetricsUrl ??
410 bootstrap?.otlpMetricsUrl ??
411 persistedObservabilitySettings.otlpMetricsUrl,
412 otlpLogsUrl:
413 env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl,
407 otlpTracesUrl: otel.disabled
408 ? undefined
409 : (env.otlpTracesUrl ??
410 bootstrap?.otlpTracesUrl ??
411 persistedObservabilitySettings.otlpTracesUrl),
412 otlpMetricsUrl: otel.disabled
413 ? undefined
414 : (env.otlpMetricsUrl ??
415 bootstrap?.otlpMetricsUrl ??
416 persistedObservabilitySettings.otlpMetricsUrl),
417 otlpLogsUrl: otel.disabled
418 ? undefined
419 : (env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl),
414420 otlpTracesExport: signalExport,
415421 otlpMetricsExport: signalExport,
416422 otlpLogsExport: signalExport,
417423 otlpServiceName: env.otlpServiceName,
424 otelEnvironment: otel,
418425 mode,
419426 port,
420427 cwd,
apps/server/src/cli/pair.ts+2
......@@ -16,6 +16,7 @@ import {
1616} from "@t3tools/contracts";
1717import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
1818import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
19import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
1920import {
2021 buildTailscaleHttpsBaseUrl,
2122 DEFAULT_TAILSCALE_SERVE_PORT,
......@@ -326,6 +327,7 @@ const makePairServerConfig = Effect.fn(function* (input: {
326327 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
327328 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
328329 otlpServiceName: "t3-server",
330 otelEnvironment: OtelEnvironment.none,
329331 mode: "web",
330332 port: state.port,
331333 host: state.host,
apps/server/src/config.ts+3
......@@ -18,6 +18,7 @@ import * as Schema from "effect/Schema";
1818
1919import { sweepStalePendingAttachments } from "./attachmentStore.ts";
2020import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability";
21import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
2122
2223export const DEFAULT_PORT = 3773;
2324
......@@ -82,6 +83,7 @@ export class ServerConfig extends Context.Service<
8283 readonly otlpMetricsExport: SignalExport;
8384 readonly otlpLogsExport: SignalExport;
8485 readonly otlpServiceName: string;
86 readonly otelEnvironment: OtelEnvironment.OtelEnvironment;
8587 readonly mode: RuntimeMode;
8688 readonly port: number;
8789 readonly host: string | undefined;
......@@ -221,6 +223,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
221223 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
222224 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
223225 otlpServiceName: "t3-server",
226 otelEnvironment: OtelEnvironment.none,
224227 cwd,
225228 baseDir,
226229 ...derivedPaths,
apps/server/src/environment/ServerEnvironment.test.ts+2
......@@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError";
1111import * as Schema from "effect/Schema";
1212
1313import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
14import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
1415
1516import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1617import {
......@@ -60,6 +61,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
6061 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
6162 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
6263 otlpServiceName: "t3-server",
64 otelEnvironment: OtelEnvironment.none,
6365 cwd: process.cwd(),
6466 baseDir,
6567 mode: "web",
apps/server/src/observability/Layers/Observability.ts+11-1
......@@ -20,6 +20,7 @@ import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";
2020export const ObservabilityLive = Layer.unwrap(
2121 Effect.gen(function* () {
2222 const config = yield* ServerConfig.ServerConfig;
23
2324 const traces = config.otlpTracesExport;
2425 const metrics = config.otlpMetricsExport;
2526 // The trace serializer stays in the returned context because the browser
......@@ -86,6 +87,15 @@ export const ObservabilityLive = Layer.unwrap(
8687 resource,
8788 }).pipe(Layer.provide(otlpSerializationLayer(metrics.protocol)));
8889
89 return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer);
90 // Logged once the server's loggers are installed, so the warnings use them.
91 const otelWarningsLayer = Layer.effectDiscard(
92 Effect.forEach(config.otelEnvironment.warnings, (warning) => Effect.logWarning(warning)),
93 );
94
95 return otelWarningsLayer.pipe(
96 Layer.provideMerge(
97 Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer),
98 ),
99 );
90100 }),
91101);
apps/server/src/server.test.ts+2
......@@ -223,6 +223,7 @@ import {
223223} from "../integration/TransferBudgetReport.integration.ts";
224224import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
225225import { DEFAULT_SIGNAL_EXPORT, otlpSerializationLayer } from "@t3tools/shared/observability";
226import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
226227
227228const defaultProjectId = ProjectId.make("project-default");
228229const defaultThreadId = ThreadId.make("thread-default");
......@@ -587,6 +588,7 @@ const buildAppUnderTest = (options?: {
587588 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
588589 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
589590 otlpServiceName: "t3-server",
591 otelEnvironment: OtelEnvironment.none,
590592 mode: "desktop",
591593 port: 0,
592594 host: "127.0.0.1",
apps/server/src/serverLogger.test.ts+2
......@@ -9,6 +9,7 @@ import * as HttpClient from "effect/unstable/http/HttpClient";
99import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
1010
1111import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
12import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
1213
1314import * as ServerConfig from "./config.ts";
1415import { ServerLoggerLive } from "./serverLogger.ts";
......@@ -57,6 +58,7 @@ const configLayer = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) =
5758 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
5859 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
5960 otlpServiceName: "t3-server",
61 otelEnvironment: OtelEnvironment.none,
6062 cwd: baseDir,
6163 baseDir,
6264 ...derivedPaths,
docs/operations/observability.md+12
......@@ -555,6 +555,18 @@ OTLP export:
555555If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay
556556on stdout only.
557557
558### The Kill Switch
559
560`T3CODE_OTEL_SDK_DISABLED` and `OTEL_SDK_DISABLED` turn off every OTLP export in both the server and
561the desktop main process, overriding any endpoint from the environment or Settings. Local trace
562files and stdout logs are unaffected.
563
564`T3CODE_OTEL_SDK_DISABLED` wins when set, so `T3CODE_OTEL_SDK_DISABLED=false` re-enables export on a
565machine that sets `OTEL_SDK_DISABLED` for everything else. It accepts the usual boolean spellings
566(`true`/`false`, `yes`/`no`, `on`/`off`, `1`/`0`, `y`/`n`). `OTEL_SDK_DISABLED` follows the
567OpenTelemetry specification and only `true` disables export, so `OTEL_SDK_DISABLED=1` does not.
568Values are case-insensitive and trimmed. An unrecognized value is ignored with a startup warning.
569
558570### What Is Instrumented Today
559571
560572Current high-value span and metric boundaries include:
packages/shared/package.json+4
......@@ -59,6 +59,10 @@
5959 "types": "./src/observability.ts",
6060 "import": "./src/observability.ts"
6161 },
62 "./otelEnvironment": {
63 "types": "./src/otelEnvironment.ts",
64 "import": "./src/otelEnvironment.ts"
65 },
6266 "./httpObservability": {
6367 "types": "./src/httpObservability.ts",
6468 "import": "./src/httpObservability.ts"
packages/shared/src/otelEnvironment.test.ts created+82
......@@ -0,0 +1,82 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Effect from "effect/Effect";
4
5import * as OtelEnvironment from "./otelEnvironment.ts";
6
7const load = (env: Record<string, string>) =>
8 OtelEnvironment.load.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))));
9
10const SPEC_OFF =
11 "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway";
12const T3_OFF =
13 "T3CODE_OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it";
14const specIgnored = (value: string) =>
15 `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`;
16
17describe("OtelEnvironment", () => {
18 it.effect.each([
19 { name: "nothing set", env: {}, disabled: false, warnings: [] },
20 // OTEL_SDK_DISABLED follows the specification: only `true`, case-insensitively.
21 { name: "spec true", env: { OTEL_SDK_DISABLED: "true" }, disabled: true, warnings: [SPEC_OFF] },
22 { name: "spec True", env: { OTEL_SDK_DISABLED: "True" }, disabled: true, warnings: [SPEC_OFF] },
23 {
24 name: "spec padded",
25 env: { OTEL_SDK_DISABLED: " true " },
26 disabled: true,
27 warnings: [SPEC_OFF],
28 },
29 { name: "spec false", env: { OTEL_SDK_DISABLED: "false" }, disabled: false, warnings: [] },
30 {
31 name: "spec 1",
32 env: { OTEL_SDK_DISABLED: "1" },
33 disabled: false,
34 warnings: [specIgnored("1")],
35 },
36 {
37 name: "spec padded yes",
38 env: { OTEL_SDK_DISABLED: " yes " },
39 disabled: false,
40 warnings: [specIgnored("yes")],
41 },
42 // T3CODE_OTEL_SDK_DISABLED takes Config.Boolean's values, case-insensitively.
43 { name: "t3 1", env: { T3CODE_OTEL_SDK_DISABLED: "1" }, disabled: true, warnings: [T3_OFF] },
44 {
45 name: "t3 TRUE",
46 env: { T3CODE_OTEL_SDK_DISABLED: "TRUE" },
47 disabled: true,
48 warnings: [T3_OFF],
49 },
50 { name: "t3 n", env: { T3CODE_OTEL_SDK_DISABLED: "n" }, disabled: false, warnings: [] },
51 {
52 name: "t3 false overrides spec true",
53 env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "true" },
54 disabled: false,
55 warnings: [],
56 },
57 {
58 name: "blank t3 falls through",
59 env: { T3CODE_OTEL_SDK_DISABLED: " ", OTEL_SDK_DISABLED: "true" },
60 disabled: true,
61 warnings: [SPEC_OFF],
62 },
63 {
64 name: "unreadable t3 warns and falls through",
65 env: { T3CODE_OTEL_SDK_DISABLED: "maybe", OTEL_SDK_DISABLED: "true" },
66 disabled: true,
67 warnings: ["T3CODE_OTEL_SDK_DISABLED=maybe is not a yes or a no and was ignored", SPEC_OFF],
68 },
69 {
70 name: "bad spec value still warns when t3 answered",
71 env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "yes" },
72 disabled: false,
73 warnings: [specIgnored("yes")],
74 },
75 ])("$name", ({ env, disabled, warnings }) =>
76 Effect.gen(function* () {
77 const resolved = yield* load(env);
78 assert.strictEqual(resolved.disabled, disabled);
79 assert.deepStrictEqual(resolved.warnings, warnings);
80 }),
81 );
82});
packages/shared/src/otelEnvironment.ts created+105
......@@ -0,0 +1,105 @@
1/**
2 * otelEnvironment: the OpenTelemetry kill switch, shared by the server and the
3 * desktop main process so both agree on what turns export off.
4 *
5 * `T3CODE_OTEL_SDK_DISABLED` is read first, so a machine that sets
6 * `OTEL_SDK_DISABLED` for everything else can still opt T3 Code back in.
7 *
8 * @module otelEnvironment
9 */
10import * as Config from "effect/Config";
11import * as Effect from "effect/Effect";
12import * as Schema from "effect/Schema";
13import * as SchemaTransformation from "effect/SchemaTransformation";
14
15export interface OtelEnvironment {
16 /** Whether OTLP export is off, whatever endpoint is configured. */
17 readonly disabled: boolean;
18 /** Messages for the caller to log once at startup. */
19 readonly warnings: ReadonlyArray<string>;
20}
21
22interface Flag {
23 /** `undefined` when the variable is unset, blank, or unreadable. */
24 readonly value: boolean | undefined;
25 readonly warning?: string;
26}
27
28const TrimmedLowercase = Schema.String.pipe(
29 Schema.decodeTo(
30 Schema.String,
31 SchemaTransformation.trim().compose(SchemaTransformation.toLowerCase()),
32 ),
33);
34
35/**
36 * Reads a boolean that accepts `truthy` and `falsy`, ignoring case and padding.
37 * Any other value is ignored with a warning rather than failing startup.
38 */
39const flag = (
40 name: string,
41 truthy: ReadonlyArray<string>,
42 falsy: ReadonlyArray<string>,
43 invalid: (value: string) => string,
44) =>
45 Config.schema(
46 TrimmedLowercase.pipe(Schema.decodeTo(Schema.Literals([...truthy, ...falsy]))),
47 name,
48 ).pipe(
49 Config.map((value): Flag => ({ value: truthy.includes(value) })),
50 Config.orElse(() =>
51 Config.String(name).pipe(
52 Config.map((raw): Flag => {
53 const value = raw.trim();
54 return value === ""
55 ? { value: undefined }
56 : { value: undefined, warning: invalid(value) };
57 }),
58 ),
59 ),
60 Config.withDefault<Flag>({ value: undefined }),
61 );
62
63// `Config.Boolean`'s literals, which effect does not export on their own.
64const T3CODE_TRUE = ["true", "yes", "on", "1", "y"];
65const T3CODE_FALSE = ["false", "no", "off", "0", "n"];
66
67export const load: Effect.Effect<OtelEnvironment> = Config.all({
68 t3: flag(
69 "T3CODE_OTEL_SDK_DISABLED",
70 T3CODE_TRUE,
71 T3CODE_FALSE,
72 (value) => `T3CODE_OTEL_SDK_DISABLED=${value} is not a yes or a no and was ignored`,
73 ),
74 // The specification: a boolean it defines is true "only by the
75 // case-insensitive string `true`", implementations "MUST NOT" accept other
76 // values as true, and should warn about unrecognized ones.
77 spec: flag(
78 "OTEL_SDK_DISABLED",
79 ["true"],
80 ["false"],
81 (value) =>
82 `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`,
83 ),
84}).pipe(
85 Effect.map(({ t3, spec }) => {
86 const disabled = t3.value ?? spec.value ?? false;
87 const warnings = [t3.warning, spec.warning].filter((warning) => warning !== undefined);
88 if (disabled) {
89 warnings.push(
90 t3.value
91 ? "T3CODE_OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it"
92 : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway",
93 );
94 }
95 return { disabled, warnings };
96 }),
97 // Every read above falls back instead of failing, so this cannot happen.
98 Effect.orDie,
99);
100
101/** An environment that asked for nothing, for tests and for the pairing CLI. */
102export const none: OtelEnvironment = {
103 disabled: false,
104 warnings: [],
105};