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 @@...@@ -1,6 +1,7 @@
1import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";1import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";3import { assert, describe, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
...@@ -427,6 +428,36 @@ describe("DesktopObservability", () => {...@@ -427,6 +428,36 @@ describe("DesktopObservability", () => {
427 );428 );
428 });429 });
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
430 it.effect("reads every signal endpoint from Settings when the environment names none", () => {461 it.effect("reads every signal endpoint from Settings when the environment names none", () => {
431 const requests: Array<ExportedRequest> = [];462 const requests: Array<ExportedRequest> = [];
432 return Effect.gen(function* () {463 return Effect.gen(function* () {
...@@ -491,4 +522,37 @@ describe("DesktopObservability", () => {...@@ -491,4 +522,37 @@ describe("DesktopObservability", () => {
491 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),522 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
492 );523 );
493 });524 });
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 });
494});558});
apps/desktop/src/app/DesktopObservability.ts+13-1
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 makeTraceSink,4 makeTraceSink,
5 otlpSerializationLayer,5 otlpSerializationLayer,
6} from "@t3tools/shared/observability";6} from "@t3tools/shared/observability";
7import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
7import {8import {
8 parsePersistedServerObservabilitySettings,9 parsePersistedServerObservabilitySettings,
9 type PersistedServerObservabilitySettings,10 type PersistedServerObservabilitySettings,
...@@ -353,12 +354,18 @@ const readPersistedObservabilitySettings: Effect.Effect<...@@ -353,12 +354,18 @@ const readPersistedObservabilitySettings: Effect.Effect<
353 * resolve traces against one revision of the file and logs against another.354 * resolve traces against one revision of the file and logs against another.
354 */355 */
355const resolveOtlpEndpoints = Effect.gen(function* () {356const 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
356 const environment = yield* DesktopEnvironment.DesktopEnvironment;362 const environment = yield* DesktopEnvironment.DesktopEnvironment;
357 const persisted = yield* readPersistedObservabilitySettings;363 const persisted = yield* readPersistedObservabilitySettings;
358 return {364 return {
359 traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl,365 traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl,
360 metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl,366 metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl,
361 logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl,367 logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl,
368 warnings: otel.warnings,
362 };369 };
363});370});
364371
...@@ -671,7 +678,12 @@ const telemetryLayer = Layer.unwrap(...@@ -671,7 +678,12 @@ const telemetryLayer = Layer.unwrap(
671 // resource,678 // resource,
672 // }).pipe(Layer.provide(serializationLayer));679 // }).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)));
675 }),687 }),
676);688);
677689
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+43
...@@ -946,6 +946,49 @@ describe("DesktopBackendConfiguration", () => {...@@ -946,6 +946,49 @@ describe("DesktopBackendConfiguration", () => {
946 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),946 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
947 );947 );
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
949 it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () =>992 it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () =>
950 Effect.gen(function* () {993 Effect.gen(function* () {
951 const fileSystem = yield* FileSystem.FileSystem;994 const fileSystem = yield* FileSystem.FileSystem;
apps/desktop/src/backend/DesktopBackendConfiguration.ts+3
...@@ -96,6 +96,9 @@ const DESKTOP_BACKEND_ENV_NAMES = [...@@ -96,6 +96,9 @@ const DESKTOP_BACKEND_ENV_NAMES = [
96const WSL_FORWARDED_ENV_NAMES = [96const WSL_FORWARDED_ENV_NAMES = [
97 "OPENAI_API_KEY",97 "OPENAI_API_KEY",
98 "ANTHROPIC_API_KEY",98 "ANTHROPIC_API_KEY",
99 // Otherwise the WSL server keeps exporting to endpoints from the bootstrap.
100 "T3CODE_OTEL_SDK_DISABLED",
101 "OTEL_SDK_DISABLED",
99 "T3CODE_OTLP_HEADERS",102 "T3CODE_OTLP_HEADERS",
100 "T3CODE_OTLP_PROTOCOL",103 "T3CODE_OTLP_PROTOCOL",
101] as const;104] as const;
apps/server/src/bin.test.ts+2
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16import * as NetService from "@t3tools/shared/Net";16import * as NetService from "@t3tools/shared/Net";
17import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";17import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
18import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";18import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
19import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
19import { assert, it } from "@effect/vitest";20import { assert, it } from "@effect/vitest";
20import * as Effect from "effect/Effect";21import * as Effect from "effect/Effect";
21import * as DateTime from "effect/DateTime";22import * as DateTime from "effect/DateTime";
...@@ -106,6 +107,7 @@ const makeCliTestServerConfig = (baseDir: string) =>...@@ -106,6 +107,7 @@ const makeCliTestServerConfig = (baseDir: string) =>
106 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,107 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
107 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,108 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
108 otlpServiceName: "t3-server",109 otlpServiceName: "t3-server",
110 otelEnvironment: OtelEnvironment.none,
109 mode: "web",111 mode: "web",
110 port: 0,112 port: 0,
111 host: "127.0.0.1",113 host: "127.0.0.1",
apps/server/src/cli/config.test.ts+105
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import * as NetService from "@t3tools/shared/Net";19import * as NetService from "@t3tools/shared/Net";
20import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";20import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
21import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
21import * as NodeServices from "@effect/platform-node/NodeServices";22import * as NodeServices from "@effect/platform-node/NodeServices";
22import { deriveServerPaths } from "../config.ts";23import { deriveServerPaths } from "../config.ts";
23import { resolveServerConfig } from "./config.ts";24import { resolveServerConfig } from "./config.ts";
...@@ -56,6 +57,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -56,6 +57,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
56 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,57 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
57 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,58 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
58 otlpServiceName: "t3-server",59 otlpServiceName: "t3-server",
60 otelEnvironment: OtelEnvironment.none,
59 devAllowedOrigins: [],61 devAllowedOrigins: [],
60 } as const;62 } as const;
6163
...@@ -666,6 +668,109 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -666,6 +668,109 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
666 }),668 }),
667 );669 );
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
669 it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () =>774 it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () =>
670 Effect.gen(function* () {775 Effect.gen(function* () {
671 const { join } = yield* Path.Path;776 const { join } = yield* Path.Path;
apps/server/src/cli/config.ts+17-10
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 OtlpProtocol,4 OtlpProtocol,
5 type SignalExport,5 type SignalExport,
6} from "@t3tools/shared/observability";6} from "@t3tools/shared/observability";
7import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
7import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";8import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
8import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";9import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";
9import * as Config from "effect/Config";10import * as Config from "effect/Config";
...@@ -386,6 +387,8 @@ export const resolveServerConfig = (...@@ -386,6 +387,8 @@ export const resolveServerConfig = (
386 );387 );
387 const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel);388 const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel);
388389
390 const otel = yield* OtelEnvironment.load;
391
389 // T3 Code's own OTLP variables name no signal, so the one answer they give392 // T3 Code's own OTLP variables name no signal, so the one answer they give
390 // is the answer for all three.393 // is the answer for all three.
391 const signalExport: SignalExport = {394 const signalExport: SignalExport = {
...@@ -401,20 +404,24 @@ export const resolveServerConfig = (...@@ -401,20 +404,24 @@ export const resolveServerConfig = (
401 traceBatchWindowMs: env.traceBatchWindowMs,404 traceBatchWindowMs: env.traceBatchWindowMs,
402 traceMaxBytes: env.traceMaxBytes,405 traceMaxBytes: env.traceMaxBytes,
403 traceMaxFiles: env.traceMaxFiles,406 traceMaxFiles: env.traceMaxFiles,
404 otlpTracesUrl:407 otlpTracesUrl: otel.disabled
405 env.otlpTracesUrl ??408 ? undefined
406 bootstrap?.otlpTracesUrl ??409 : (env.otlpTracesUrl ??
407 persistedObservabilitySettings.otlpTracesUrl,410 bootstrap?.otlpTracesUrl ??
408 otlpMetricsUrl:411 persistedObservabilitySettings.otlpTracesUrl),
409 env.otlpMetricsUrl ??412 otlpMetricsUrl: otel.disabled
410 bootstrap?.otlpMetricsUrl ??413 ? undefined
411 persistedObservabilitySettings.otlpMetricsUrl,414 : (env.otlpMetricsUrl ??
412 otlpLogsUrl:415 bootstrap?.otlpMetricsUrl ??
413 env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl,416 persistedObservabilitySettings.otlpMetricsUrl),
417 otlpLogsUrl: otel.disabled
418 ? undefined
419 : (env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl),
414 otlpTracesExport: signalExport,420 otlpTracesExport: signalExport,
415 otlpMetricsExport: signalExport,421 otlpMetricsExport: signalExport,
416 otlpLogsExport: signalExport,422 otlpLogsExport: signalExport,
417 otlpServiceName: env.otlpServiceName,423 otlpServiceName: env.otlpServiceName,
424 otelEnvironment: otel,
418 mode,425 mode,
419 port,426 port,
420 cwd,427 cwd,
apps/server/src/cli/pair.ts+2
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";17import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
18import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";18import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
19import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
19import {20import {
20 buildTailscaleHttpsBaseUrl,21 buildTailscaleHttpsBaseUrl,
21 DEFAULT_TAILSCALE_SERVE_PORT,22 DEFAULT_TAILSCALE_SERVE_PORT,
...@@ -326,6 +327,7 @@ const makePairServerConfig = Effect.fn(function* (input: {...@@ -326,6 +327,7 @@ const makePairServerConfig = Effect.fn(function* (input: {
326 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,327 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
327 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,328 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
328 otlpServiceName: "t3-server",329 otlpServiceName: "t3-server",
330 otelEnvironment: OtelEnvironment.none,
329 mode: "web",331 mode: "web",
330 port: state.port,332 port: state.port,
331 host: state.host,333 host: state.host,
apps/server/src/config.ts+3
...@@ -18,6 +18,7 @@ import * as Schema from "effect/Schema";...@@ -18,6 +18,7 @@ import * as Schema from "effect/Schema";
1818
19import { sweepStalePendingAttachments } from "./attachmentStore.ts";19import { sweepStalePendingAttachments } from "./attachmentStore.ts";
20import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability";20import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability";
21import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
2122
22export const DEFAULT_PORT = 3773;23export const DEFAULT_PORT = 3773;
2324
...@@ -82,6 +83,7 @@ export class ServerConfig extends Context.Service<...@@ -82,6 +83,7 @@ export class ServerConfig extends Context.Service<
82 readonly otlpMetricsExport: SignalExport;83 readonly otlpMetricsExport: SignalExport;
83 readonly otlpLogsExport: SignalExport;84 readonly otlpLogsExport: SignalExport;
84 readonly otlpServiceName: string;85 readonly otlpServiceName: string;
86 readonly otelEnvironment: OtelEnvironment.OtelEnvironment;
85 readonly mode: RuntimeMode;87 readonly mode: RuntimeMode;
86 readonly port: number;88 readonly port: number;
87 readonly host: string | undefined;89 readonly host: string | undefined;
...@@ -221,6 +223,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (...@@ -221,6 +223,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
221 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,223 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
222 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,224 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
223 otlpServiceName: "t3-server",225 otlpServiceName: "t3-server",
226 otelEnvironment: OtelEnvironment.none,
224 cwd,227 cwd,
225 baseDir,228 baseDir,
226 ...derivedPaths,229 ...derivedPaths,
apps/server/src/environment/ServerEnvironment.test.ts+2
...@@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError";...@@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError";
11import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
1212
13import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";13import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
14import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
1415
15import * as ServerSecretStore from "../auth/ServerSecretStore.ts";16import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
16import {17import {
...@@ -60,6 +61,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {...@@ -60,6 +61,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
60 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,61 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
61 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,62 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
62 otlpServiceName: "t3-server",63 otlpServiceName: "t3-server",
64 otelEnvironment: OtelEnvironment.none,
63 cwd: process.cwd(),65 cwd: process.cwd(),
64 baseDir,66 baseDir,
65 mode: "web",67 mode: "web",
apps/server/src/observability/Layers/Observability.ts+11-1
...@@ -20,6 +20,7 @@ import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";...@@ -20,6 +20,7 @@ import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";
20export const ObservabilityLive = Layer.unwrap(20export const ObservabilityLive = Layer.unwrap(
21 Effect.gen(function* () {21 Effect.gen(function* () {
22 const config = yield* ServerConfig.ServerConfig;22 const config = yield* ServerConfig.ServerConfig;
23
23 const traces = config.otlpTracesExport;24 const traces = config.otlpTracesExport;
24 const metrics = config.otlpMetricsExport;25 const metrics = config.otlpMetricsExport;
25 // The trace serializer stays in the returned context because the browser26 // The trace serializer stays in the returned context because the browser
...@@ -86,6 +87,15 @@ export const ObservabilityLive = Layer.unwrap(...@@ -86,6 +87,15 @@ export const ObservabilityLive = Layer.unwrap(
86 resource,87 resource,
87 }).pipe(Layer.provide(otlpSerializationLayer(metrics.protocol)));88 }).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 );
90 }),100 }),
91);101);
apps/server/src/server.test.ts+2
...@@ -223,6 +223,7 @@ import {...@@ -223,6 +223,7 @@ import {
223} from "../integration/TransferBudgetReport.integration.ts";223} from "../integration/TransferBudgetReport.integration.ts";
224import { symlinksSupported } from "@t3tools/shared/testing/symlinks";224import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
225import { DEFAULT_SIGNAL_EXPORT, otlpSerializationLayer } from "@t3tools/shared/observability";225import { DEFAULT_SIGNAL_EXPORT, otlpSerializationLayer } from "@t3tools/shared/observability";
226import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
226227
227const defaultProjectId = ProjectId.make("project-default");228const defaultProjectId = ProjectId.make("project-default");
228const defaultThreadId = ThreadId.make("thread-default");229const defaultThreadId = ThreadId.make("thread-default");
...@@ -587,6 +588,7 @@ const buildAppUnderTest = (options?: {...@@ -587,6 +588,7 @@ const buildAppUnderTest = (options?: {
587 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,588 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
588 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,589 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
589 otlpServiceName: "t3-server",590 otlpServiceName: "t3-server",
591 otelEnvironment: OtelEnvironment.none,
590 mode: "desktop",592 mode: "desktop",
591 port: 0,593 port: 0,
592 host: "127.0.0.1",594 host: "127.0.0.1",
apps/server/src/serverLogger.test.ts+2
...@@ -9,6 +9,7 @@ import * as HttpClient from "effect/unstable/http/HttpClient";...@@ -9,6 +9,7 @@ import * as HttpClient from "effect/unstable/http/HttpClient";
9import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";9import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
1010
11import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";11import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
12import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
1213
13import * as ServerConfig from "./config.ts";14import * as ServerConfig from "./config.ts";
14import { ServerLoggerLive } from "./serverLogger.ts";15import { ServerLoggerLive } from "./serverLogger.ts";
...@@ -57,6 +58,7 @@ const configLayer = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) =...@@ -57,6 +58,7 @@ const configLayer = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) =
57 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,58 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
58 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,59 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
59 otlpServiceName: "t3-server",60 otlpServiceName: "t3-server",
61 otelEnvironment: OtelEnvironment.none,
60 cwd: baseDir,62 cwd: baseDir,
61 baseDir,63 baseDir,
62 ...derivedPaths,64 ...derivedPaths,
docs/operations/observability.md+12
...@@ -555,6 +555,18 @@ OTLP export:...@@ -555,6 +555,18 @@ OTLP export:
555If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay555If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay
556on stdout only.556on 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
558### What Is Instrumented Today570### What Is Instrumented Today
559571
560Current high-value span and metric boundaries include:572Current high-value span and metric boundaries include:
packages/shared/package.json+4
...@@ -59,6 +59,10 @@...@@ -59,6 +59,10 @@
59 "types": "./src/observability.ts",59 "types": "./src/observability.ts",
60 "import": "./src/observability.ts"60 "import": "./src/observability.ts"
61 },61 },
62 "./otelEnvironment": {
63 "types": "./src/otelEnvironment.ts",
64 "import": "./src/otelEnvironment.ts"
65 },
62 "./httpObservability": {66 "./httpObservability": {
63 "types": "./src/httpObservability.ts",67 "types": "./src/httpObservability.ts",
64 "import": "./src/httpObservability.ts"68 "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};