| author | |
| committer | |
| log | e67abcf798f8c4d8458755e3b4dde02c2c1f628b |
| tree | c2009ddf7ba9f14f3033def3d10bed2b92ba7a47 |
| parent | 9383f4ad71285b9f9a6a9b65f2f961ea2cf4308f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1 | import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | import { assert, describe, it } from "@effect/vitest"; |
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 4 | 5 | import * as Effect from "effect/Effect"; |
| 5 | 6 | import * as FileSystem from "effect/FileSystem"; |
| 6 | 7 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -427,6 +428,36 @@ describe("DesktopObservability", () => { |
| 427 | 428 | ); |
| 428 | 429 | }); |
| 429 | 430 | |
| 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 | 461 | it.effect("reads every signal endpoint from Settings when the environment names none", () => { |
| 431 | 462 | const requests: Array<ExportedRequest> = []; |
| 432 | 463 | return Effect.gen(function* () { |
| ... | ... | @@ -491,4 +522,37 @@ describe("DesktopObservability", () => { |
| 491 | 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 | 4 | makeTraceSink, |
| 5 | 5 | otlpSerializationLayer, |
| 6 | 6 | } from "@t3tools/shared/observability"; |
| 7 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | |
| 7 | 8 | import { |
| 8 | 9 | parsePersistedServerObservabilitySettings, |
| 9 | 10 | type PersistedServerObservabilitySettings, |
| ... | ... | @@ -353,12 +354,18 @@ const readPersistedObservabilitySettings: Effect.Effect< |
| 353 | 354 | * resolve traces against one revision of the file and logs against another. |
| 354 | 355 | */ |
| 355 | 356 | const 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 | 362 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 357 | 363 | const persisted = yield* readPersistedObservabilitySettings; |
| 358 | 364 | return { |
| 359 | 365 | traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl, |
| 360 | 366 | metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl, |
| 361 | 367 | logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl, |
| 368 | warnings: otel.warnings, | |
| 362 | 369 | }; |
| 363 | 370 | }); |
| 364 | 371 | |
| ... | ... | @@ -671,7 +678,12 @@ const telemetryLayer = Layer.unwrap( |
| 671 | 678 | // resource, |
| 672 | 679 | // }).pipe(Layer.provide(serializationLayer)); |
| 673 | 680 | |
| 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 | ); |
| 677 | 689 |
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+43| ... | ... | @@ -946,6 +946,49 @@ describe("DesktopBackendConfiguration", () => { |
| 946 | 946 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 947 | 947 | ); |
| 948 | 948 | |
| 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 | 992 | it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => |
| 950 | 993 | Effect.gen(function* () { |
| 951 | 994 | const fileSystem = yield* FileSystem.FileSystem; |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+3| ... | ... | @@ -96,6 +96,9 @@ const DESKTOP_BACKEND_ENV_NAMES = [ |
| 96 | 96 | const WSL_FORWARDED_ENV_NAMES = [ |
| 97 | 97 | "OPENAI_API_KEY", |
| 98 | 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 | 102 | "T3CODE_OTLP_HEADERS", |
| 100 | 103 | "T3CODE_OTLP_PROTOCOL", |
| 101 | 104 | ] as const; |
apps/server/src/bin.test.ts+2| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | import * as NetService from "@t3tools/shared/Net"; |
| 17 | 17 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 18 | 18 | import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; |
| 19 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | |
| 19 | 20 | import { assert, it } from "@effect/vitest"; |
| 20 | 21 | import * as Effect from "effect/Effect"; |
| 21 | 22 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -106,6 +107,7 @@ const makeCliTestServerConfig = (baseDir: string) => |
| 106 | 107 | otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, |
| 107 | 108 | otlpLogsExport: DEFAULT_SIGNAL_EXPORT, |
| 108 | 109 | otlpServiceName: "t3-server", |
| 110 | otelEnvironment: OtelEnvironment.none, | |
| 109 | 111 | mode: "web", |
| 110 | 112 | port: 0, |
| 111 | 113 | host: "127.0.0.1", |
apps/server/src/cli/config.test.ts+105| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | } from "@t3tools/contracts"; |
| 19 | 19 | import * as NetService from "@t3tools/shared/Net"; |
| 20 | 20 | import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; |
| 21 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | |
| 21 | 22 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 22 | 23 | import { deriveServerPaths } from "../config.ts"; |
| 23 | 24 | import { resolveServerConfig } from "./config.ts"; |
| ... | ... | @@ -56,6 +57,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 56 | 57 | otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, |
| 57 | 58 | otlpLogsExport: DEFAULT_SIGNAL_EXPORT, |
| 58 | 59 | otlpServiceName: "t3-server", |
| 60 | otelEnvironment: OtelEnvironment.none, | |
| 59 | 61 | devAllowedOrigins: [], |
| 60 | 62 | } as const; |
| 61 | 63 | |
| ... | ... | @@ -666,6 +668,109 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 666 | 668 | }), |
| 667 | 669 | ); |
| 668 | 670 | |
| 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 | 774 | it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () => |
| 670 | 775 | Effect.gen(function* () { |
| 671 | 776 | const { join } = yield* Path.Path; |
apps/server/src/cli/config.ts+17-10| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | OtlpProtocol, |
| 5 | 5 | type SignalExport, |
| 6 | 6 | } from "@t3tools/shared/observability"; |
| 7 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | |
| 7 | 8 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 8 | 9 | import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; |
| 9 | 10 | import * as Config from "effect/Config"; |
| ... | ... | @@ -386,6 +387,8 @@ export const resolveServerConfig = ( |
| 386 | 387 | ); |
| 387 | 388 | const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); |
| 388 | 389 | |
| 390 | const otel = yield* OtelEnvironment.load; | |
| 391 | ||
| 389 | 392 | // T3 Code's own OTLP variables name no signal, so the one answer they give |
| 390 | 393 | // is the answer for all three. |
| 391 | 394 | const signalExport: SignalExport = { |
| ... | ... | @@ -401,20 +404,24 @@ export const resolveServerConfig = ( |
| 401 | 404 | traceBatchWindowMs: env.traceBatchWindowMs, |
| 402 | 405 | traceMaxBytes: env.traceMaxBytes, |
| 403 | 406 | 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), | |
| 414 | 420 | otlpTracesExport: signalExport, |
| 415 | 421 | otlpMetricsExport: signalExport, |
| 416 | 422 | otlpLogsExport: signalExport, |
| 417 | 423 | otlpServiceName: env.otlpServiceName, |
| 424 | otelEnvironment: otel, | |
| 418 | 425 | mode, |
| 419 | 426 | port, |
| 420 | 427 | cwd, |
apps/server/src/cli/pair.ts+2| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | } from "@t3tools/contracts"; |
| 17 | 17 | import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; |
| 18 | 18 | import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; |
| 19 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | |
| 19 | 20 | import { |
| 20 | 21 | buildTailscaleHttpsBaseUrl, |
| 21 | 22 | DEFAULT_TAILSCALE_SERVE_PORT, |
| ... | ... | @@ -326,6 +327,7 @@ const makePairServerConfig = Effect.fn(function* (input: { |
| 326 | 327 | otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, |
| 327 | 328 | otlpLogsExport: DEFAULT_SIGNAL_EXPORT, |
| 328 | 329 | otlpServiceName: "t3-server", |
| 330 | otelEnvironment: OtelEnvironment.none, | |
| 329 | 331 | mode: "web", |
| 330 | 332 | port: state.port, |
| 331 | 333 | host: state.host, |
apps/server/src/config.ts+3| ... | ... | @@ -18,6 +18,7 @@ import * as Schema from "effect/Schema"; |
| 18 | 18 | |
| 19 | 19 | import { sweepStalePendingAttachments } from "./attachmentStore.ts"; |
| 20 | 20 | import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability"; |
| 21 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | |
| 21 | 22 | |
| 22 | 23 | export const DEFAULT_PORT = 3773; |
| 23 | 24 | |
| ... | ... | @@ -82,6 +83,7 @@ export class ServerConfig extends Context.Service< |
| 82 | 83 | readonly otlpMetricsExport: SignalExport; |
| 83 | 84 | readonly otlpLogsExport: SignalExport; |
| 84 | 85 | readonly otlpServiceName: string; |
| 86 | readonly otelEnvironment: OtelEnvironment.OtelEnvironment; | |
| 85 | 87 | readonly mode: RuntimeMode; |
| 86 | 88 | readonly port: number; |
| 87 | 89 | readonly host: string | undefined; |
| ... | ... | @@ -221,6 +223,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( |
| 221 | 223 | otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, |
| 222 | 224 | otlpLogsExport: DEFAULT_SIGNAL_EXPORT, |
| 223 | 225 | otlpServiceName: "t3-server", |
| 226 | otelEnvironment: OtelEnvironment.none, | |
| 224 | 227 | cwd, |
| 225 | 228 | baseDir, |
| 226 | 229 | ...derivedPaths, |
apps/server/src/environment/ServerEnvironment.test.ts+2| ... | ... | @@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError"; |
| 11 | 11 | import * as Schema from "effect/Schema"; |
| 12 | 12 | |
| 13 | 13 | import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; |
| 14 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | |
| 14 | 15 | |
| 15 | 16 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 16 | 17 | import { |
| ... | ... | @@ -60,6 +61,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { |
| 60 | 61 | otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, |
| 61 | 62 | otlpLogsExport: DEFAULT_SIGNAL_EXPORT, |
| 62 | 63 | otlpServiceName: "t3-server", |
| 64 | otelEnvironment: OtelEnvironment.none, | |
| 63 | 65 | cwd: process.cwd(), |
| 64 | 66 | baseDir, |
| 65 | 67 | mode: "web", |
apps/server/src/observability/Layers/Observability.ts+11-1| ... | ... | @@ -20,6 +20,7 @@ import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; |
| 20 | 20 | export const ObservabilityLive = Layer.unwrap( |
| 21 | 21 | Effect.gen(function* () { |
| 22 | 22 | const config = yield* ServerConfig.ServerConfig; |
| 23 | ||
| 23 | 24 | const traces = config.otlpTracesExport; |
| 24 | 25 | const metrics = config.otlpMetricsExport; |
| 25 | 26 | // The trace serializer stays in the returned context because the browser |
| ... | ... | @@ -86,6 +87,15 @@ export const ObservabilityLive = Layer.unwrap( |
| 86 | 87 | resource, |
| 87 | 88 | }).pipe(Layer.provide(otlpSerializationLayer(metrics.protocol))); |
| 88 | 89 | |
| 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 | 223 | } from "../integration/TransferBudgetReport.integration.ts"; |
| 224 | 224 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 225 | 225 | import { DEFAULT_SIGNAL_EXPORT, otlpSerializationLayer } from "@t3tools/shared/observability"; |
| 226 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | |
| 226 | 227 | |
| 227 | 228 | const defaultProjectId = ProjectId.make("project-default"); |
| 228 | 229 | const defaultThreadId = ThreadId.make("thread-default"); |
| ... | ... | @@ -587,6 +588,7 @@ const buildAppUnderTest = (options?: { |
| 587 | 588 | otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, |
| 588 | 589 | otlpLogsExport: DEFAULT_SIGNAL_EXPORT, |
| 589 | 590 | otlpServiceName: "t3-server", |
| 591 | otelEnvironment: OtelEnvironment.none, | |
| 590 | 592 | mode: "desktop", |
| 591 | 593 | port: 0, |
| 592 | 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 | 9 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; |
| 10 | 10 | |
| 11 | 11 | import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; |
| 12 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | |
| 12 | 13 | |
| 13 | 14 | import * as ServerConfig from "./config.ts"; |
| 14 | 15 | import { ServerLoggerLive } from "./serverLogger.ts"; |
| ... | ... | @@ -57,6 +58,7 @@ const configLayer = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) = |
| 57 | 58 | otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, |
| 58 | 59 | otlpLogsExport: DEFAULT_SIGNAL_EXPORT, |
| 59 | 60 | otlpServiceName: "t3-server", |
| 61 | otelEnvironment: OtelEnvironment.none, | |
| 60 | 62 | cwd: baseDir, |
| 61 | 63 | baseDir, |
| 62 | 64 | ...derivedPaths, |
docs/operations/observability.md+12| ... | ... | @@ -555,6 +555,18 @@ OTLP export: |
| 555 | 555 | If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay |
| 556 | 556 | on stdout only. |
| 557 | 557 | |
| 558 | ### The Kill Switch | |
| 559 | ||
| 560 | `T3CODE_OTEL_SDK_DISABLED` and `OTEL_SDK_DISABLED` turn off every OTLP export in both the server and | |
| 561 | the desktop main process, overriding any endpoint from the environment or Settings. Local trace | |
| 562 | files 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 | |
| 565 | machine 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 | |
| 567 | OpenTelemetry specification and only `true` disables export, so `OTEL_SDK_DISABLED=1` does not. | |
| 568 | Values are case-insensitive and trimmed. An unrecognized value is ignored with a startup warning. | |
| 569 | ||
| 558 | 570 | ### What Is Instrumented Today |
| 559 | 571 | |
| 560 | 572 | Current high-value span and metric boundaries include: |
packages/shared/package.json+4| ... | ... | @@ -59,6 +59,10 @@ |
| 59 | 59 | "types": "./src/observability.ts", |
| 60 | 60 | "import": "./src/observability.ts" |
| 61 | 61 | }, |
| 62 | "./otelEnvironment": { | |
| 63 | "types": "./src/otelEnvironment.ts", | |
| 64 | "import": "./src/otelEnvironment.ts" | |
| 65 | }, | |
| 62 | 66 | "./httpObservability": { |
| 63 | 67 | "types": "./src/httpObservability.ts", |
| 64 | 68 | "import": "./src/httpObservability.ts" |
packages/shared/src/otelEnvironment.test.ts created+82| ... | ... | @@ -0,0 +1,82 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | ||
| 5 | import * as OtelEnvironment from "./otelEnvironment.ts"; | |
| 6 | ||
| 7 | const load = (env: Record<string, string>) => | |
| 8 | OtelEnvironment.load.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); | |
| 9 | ||
| 10 | const 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"; | |
| 12 | const T3_OFF = | |
| 13 | "T3CODE_OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it"; | |
| 14 | const 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 | ||
| 17 | describe("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 | */ | |
| 10 | import * as Config from "effect/Config"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Schema from "effect/Schema"; | |
| 13 | import * as SchemaTransformation from "effect/SchemaTransformation"; | |
| 14 | ||
| 15 | export 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 | ||
| 22 | interface Flag { | |
| 23 | /** `undefined` when the variable is unset, blank, or unreadable. */ | |
| 24 | readonly value: boolean | undefined; | |
| 25 | readonly warning?: string; | |
| 26 | } | |
| 27 | ||
| 28 | const 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 | */ | |
| 39 | const 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. | |
| 64 | const T3CODE_TRUE = ["true", "yes", "on", "1", "y"]; | |
| 65 | const T3CODE_FALSE = ["false", "no", "off", "0", "n"]; | |
| 66 | ||
| 67 | export 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. */ | |
| 102 | export const none: OtelEnvironment = { | |
| 103 | disabled: false, | |
| 104 | warnings: [], | |
| 105 | }; |