| author | |
| committer | |
| log | 3bb06ad910389fcfc886bd5156f41dc73f307f80 |
| tree | 73176194566be393263a33233f6b47f1a141c7d4 |
| parent | 82cd1d1aabe8baa4a5ae23f3c7b08b3fce092e08 |
| signature | Signed by PGP key B5690EEEBB952194 |
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>25 files changed, 503 insertions(+), 26 deletions(-)
apps/desktop/src/app/DesktopConfig.ts+2| ... | ... | @@ -46,6 +46,8 @@ export const DesktopConfig = Config.all({ |
| 46 | 46 | desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"), |
| 47 | 47 | desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"), |
| 48 | 48 | otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"), |
| 49 | otlpMetricsUrl: trimmedString("T3CODE_OTLP_METRICS_URL"), | |
| 50 | otlpLogsUrl: trimmedString("T3CODE_OTLP_LOGS_URL"), | |
| 49 | 51 | otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( |
| 50 | 52 | Config.withDefault(10_000), |
| 51 | 53 | ), |
apps/desktop/src/app/DesktopEnvironment.test.ts+4| ... | ... | @@ -51,6 +51,8 @@ describe("DesktopEnvironment", () => { |
| 51 | 51 | VITE_DEV_SERVER_URL: "http://localhost:5173", |
| 52 | 52 | T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", |
| 53 | 53 | T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", |
| 54 | T3CODE_OTLP_METRICS_URL: " http://127.0.0.1:4318/v1/metrics ", | |
| 55 | T3CODE_OTLP_LOGS_URL: " http://127.0.0.1:4318/v1/logs ", | |
| 54 | 56 | T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", |
| 55 | 57 | T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", |
| 56 | 58 | T3CODE_OTLP_PROTOCOL: "http/protobuf", |
| ... | ... | @@ -86,6 +88,8 @@ describe("DesktopEnvironment", () => { |
| 86 | 88 | assert.deepEqual(environment.configuredBackendPort, Option.some(4949)); |
| 87 | 89 | assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); |
| 88 | 90 | assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); |
| 91 | assert.deepEqual(environment.otlpMetricsUrl, Option.some("http://127.0.0.1:4318/v1/metrics")); | |
| 92 | assert.deepEqual(environment.otlpLogsUrl, Option.some("http://127.0.0.1:4318/v1/logs")); | |
| 89 | 93 | assert.equal(environment.otlpExportIntervalMs, 2500); |
| 90 | 94 | assert.deepEqual( |
| 91 | 95 | environment.otlpHeaders, |
apps/desktop/src/app/DesktopEnvironment.ts+4| ... | ... | @@ -72,6 +72,8 @@ export class DesktopEnvironment extends Context.Service< |
| 72 | 72 | readonly configuredBackendPort: Option.Option<number>; |
| 73 | 73 | readonly commitHashOverride: Option.Option<string>; |
| 74 | 74 | readonly otlpTracesUrl: Option.Option<string>; |
| 75 | readonly otlpMetricsUrl: Option.Option<string>; | |
| 76 | readonly otlpLogsUrl: Option.Option<string>; | |
| 75 | 77 | readonly otlpExportIntervalMs: number; |
| 76 | 78 | readonly otlpHeaders: Option.Option<Record<string, string>>; |
| 77 | 79 | readonly otlpProtocol: OtlpProtocol; |
| ... | ... | @@ -227,6 +229,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( |
| 227 | 229 | configuredBackendPort: config.configuredBackendPort, |
| 228 | 230 | commitHashOverride: config.commitHashOverride, |
| 229 | 231 | otlpTracesUrl: config.otlpTracesUrl, |
| 232 | otlpMetricsUrl: config.otlpMetricsUrl, | |
| 233 | otlpLogsUrl: config.otlpLogsUrl, | |
| 230 | 234 | otlpExportIntervalMs: config.otlpExportIntervalMs, |
| 231 | 235 | otlpHeaders: config.otlpHeaders, |
| 232 | 236 | otlpProtocol: config.otlpProtocol, |
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+103| ... | ... | @@ -23,6 +23,7 @@ const PersistedServerObservabilitySettingsDocument = Schema.Struct({ |
| 23 | 23 | observability: Schema.Struct({ |
| 24 | 24 | otlpTracesUrl: Schema.String, |
| 25 | 25 | otlpMetricsUrl: Schema.String, |
| 26 | otlpLogsUrl: Schema.String, | |
| 26 | 27 | }), |
| 27 | 28 | }); |
| 28 | 29 | |
| ... | ... | @@ -60,6 +61,9 @@ function makeEnvironmentLayer( |
| 60 | 61 | readonly resourcesPath?: string; |
| 61 | 62 | readonly appVersion?: string; |
| 62 | 63 | readonly processArch?: NodeJS.Architecture; |
| 64 | readonly otlpTracesUrl?: string; | |
| 65 | readonly otlpMetricsUrl?: string; | |
| 66 | readonly otlpLogsUrl?: string; | |
| 63 | 67 | }, |
| 64 | 68 | ) { |
| 65 | 69 | return DesktopEnvironment.layer({ |
| ... | ... | @@ -82,6 +86,9 @@ function makeEnvironmentLayer( |
| 82 | 86 | T3CODE_MODE: "desktop", |
| 83 | 87 | T3CODE_DESKTOP_LAN_HOST: "192.168.1.50", |
| 84 | 88 | VITE_DEV_SERVER_URL: options?.devServerUrl, |
| 89 | T3CODE_OTLP_TRACES_URL: options?.otlpTracesUrl, | |
| 90 | T3CODE_OTLP_METRICS_URL: options?.otlpMetricsUrl, | |
| 91 | T3CODE_OTLP_LOGS_URL: options?.otlpLogsUrl, | |
| 85 | 92 | }), |
| 86 | 93 | ), |
| 87 | 94 | ), |
| ... | ... | @@ -730,6 +737,7 @@ describe("DesktopBackendConfiguration", () => { |
| 730 | 737 | observability: { |
| 731 | 738 | otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", |
| 732 | 739 | otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", |
| 740 | otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ", | |
| 733 | 741 | }, |
| 734 | 742 | }), |
| 735 | 743 | ); |
| ... | ... | @@ -737,6 +745,7 @@ describe("DesktopBackendConfiguration", () => { |
| 737 | 745 | const config = yield* configuration.resolvePrimary; |
| 738 | 746 | assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); |
| 739 | 747 | assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); |
| 748 | assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); | |
| 740 | 749 | }), |
| 741 | 750 | ), |
| 742 | 751 | ); |
| ... | ... | @@ -749,10 +758,103 @@ describe("DesktopBackendConfiguration", () => { |
| 749 | 758 | |
| 750 | 759 | assert.isUndefined(config.bootstrap.otlpTracesUrl); |
| 751 | 760 | assert.isUndefined(config.bootstrap.otlpMetricsUrl); |
| 761 | assert.isUndefined(config.bootstrap.otlpLogsUrl); | |
| 752 | 762 | }), |
| 753 | 763 | ), |
| 754 | 764 | ); |
| 755 | 765 | |
| 766 | it.effect("resolveWsl carries environment-configured observability endpoints", () => | |
| 767 | Effect.gen(function* () { | |
| 768 | const fileSystem = yield* FileSystem.FileSystem; | |
| 769 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 770 | prefix: "t3-desktop-backend-config-test-", | |
| 771 | }); | |
| 772 | ||
| 773 | yield* Effect.gen(function* () { | |
| 774 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 775 | const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); | |
| 776 | ||
| 777 | // No settings.json exists here: the endpoints come from the desktop | |
| 778 | // process's env, which a WSL child cannot inherit, so the bootstrap | |
| 779 | // has to carry them or log export stays off inside the distro. | |
| 780 | assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); | |
| 781 | assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); | |
| 782 | assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); | |
| 783 | assert.notInclude(config.env.WSLENV ?? "", "T3CODE_OTLP_LOGS_URL"); | |
| 784 | }).pipe( | |
| 785 | Effect.provide( | |
| 786 | DesktopBackendConfiguration.layer.pipe( | |
| 787 | Layer.provideMerge(serverExposureLayer), | |
| 788 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 789 | Layer.provideMerge(DesktopWslServerTree.layerTest()), | |
| 790 | Layer.provideMerge( | |
| 791 | DesktopWslEnvironment.layerTest({ | |
| 792 | isAvailable: true, | |
| 793 | windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), | |
| 794 | getDistroIp: () => Option.some("172.27.0.99"), | |
| 795 | }), | |
| 796 | ), | |
| 797 | Layer.provideMerge( | |
| 798 | makeEnvironmentLayer(baseDir, { | |
| 799 | platform: "win32", | |
| 800 | otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", | |
| 801 | otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", | |
| 802 | otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ", | |
| 803 | }), | |
| 804 | ), | |
| 805 | ), | |
| 806 | ), | |
| 807 | ); | |
| 808 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 809 | ); | |
| 810 | ||
| 811 | it.effect("environment observability endpoints win over the persisted settings file", () => | |
| 812 | Effect.gen(function* () { | |
| 813 | const fileSystem = yield* FileSystem.FileSystem; | |
| 814 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 815 | prefix: "t3-desktop-backend-config-test-", | |
| 816 | }); | |
| 817 | ||
| 818 | yield* Effect.gen(function* () { | |
| 819 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 820 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 821 | ||
| 822 | yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { | |
| 823 | recursive: true, | |
| 824 | }); | |
| 825 | yield* fileSystem.writeFileString( | |
| 826 | environment.serverSettingsPath, | |
| 827 | yield* encodePersistedServerObservabilitySettingsDocument({ | |
| 828 | observability: { | |
| 829 | otlpTracesUrl: "http://persisted:4318/v1/traces", | |
| 830 | otlpMetricsUrl: "http://persisted:4318/v1/metrics", | |
| 831 | otlpLogsUrl: "http://persisted:4318/v1/logs", | |
| 832 | }, | |
| 833 | }), | |
| 834 | ); | |
| 835 | ||
| 836 | const config = yield* configuration.resolvePrimary; | |
| 837 | assert.equal(config.bootstrap.otlpLogsUrl, "http://env:4318/v1/logs"); | |
| 838 | // Only the logs endpoint is set in env, so the other two still come | |
| 839 | // from the settings file rather than being dropped together. | |
| 840 | assert.equal(config.bootstrap.otlpTracesUrl, "http://persisted:4318/v1/traces"); | |
| 841 | assert.equal(config.bootstrap.otlpMetricsUrl, "http://persisted:4318/v1/metrics"); | |
| 842 | }).pipe( | |
| 843 | Effect.provide( | |
| 844 | DesktopBackendConfiguration.layer.pipe( | |
| 845 | Layer.provideMerge(serverExposureLayer), | |
| 846 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 847 | Layer.provideMerge(DesktopWslServerTree.layerTest()), | |
| 848 | Layer.provideMerge(DesktopWslEnvironment.layerTest()), | |
| 849 | Layer.provideMerge( | |
| 850 | makeEnvironmentLayer(baseDir, { otlpLogsUrl: "http://env:4318/v1/logs" }), | |
| 851 | ), | |
| 852 | ), | |
| 853 | ), | |
| 854 | ); | |
| 855 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 856 | ); | |
| 857 | ||
| 756 | 858 | it.effect("logs structured context when persisted observability settings cannot be read", () => |
| 757 | 859 | Effect.gen(function* () { |
| 758 | 860 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -799,6 +901,7 @@ describe("DesktopBackendConfiguration", () => { |
| 799 | 901 | |
| 800 | 902 | assert.isUndefined(config.bootstrap.otlpTracesUrl); |
| 801 | 903 | assert.isUndefined(config.bootstrap.otlpMetricsUrl); |
| 904 | assert.isUndefined(config.bootstrap.otlpLogsUrl); | |
| 802 | 905 | |
| 803 | 906 | const error = messages |
| 804 | 907 | .flatMap((message) => (Array.isArray(message) ? message : [message])) |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+24-1| ... | ... | @@ -67,11 +67,13 @@ export class DesktopBackendConfiguration extends Context.Service< |
| 67 | 67 | interface BackendObservabilitySettings { |
| 68 | 68 | readonly otlpTracesUrl: Option.Option<string>; |
| 69 | 69 | readonly otlpMetricsUrl: Option.Option<string>; |
| 70 | readonly otlpLogsUrl: Option.Option<string>; | |
| 70 | 71 | } |
| 71 | 72 | |
| 72 | 73 | const emptyBackendObservabilitySettings: BackendObservabilitySettings = { |
| 73 | 74 | otlpTracesUrl: Option.none(), |
| 74 | 75 | otlpMetricsUrl: Option.none(), |
| 76 | otlpLogsUrl: Option.none(), | |
| 75 | 77 | }; |
| 76 | 78 | |
| 77 | 79 | const DESKTOP_BACKEND_ENV_NAMES = [ |
| ... | ... | @@ -212,9 +214,26 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { |
| 212 | 214 | return { |
| 213 | 215 | otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl), |
| 214 | 216 | otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl), |
| 217 | otlpLogsUrl: Option.fromNullishOr(parsed.otlpLogsUrl), | |
| 215 | 218 | }; |
| 216 | 219 | }); |
| 217 | 220 | |
| 221 | // The bootstrap is the only channel that carries an OTLP endpoint to every | |
| 222 | // backend. A Windows-native child inherits the desktop process's env, but a | |
| 223 | // WSL child gets nothing across wsl.exe that WSLENV does not declare, and | |
| 224 | // WSLENV translation of URL-shaped values is unreliable, so the endpoints are | |
| 225 | // deliberately not forwarded that way. Env beats the persisted settings file, | |
| 226 | // matching the precedence resolveServerConfig and DesktopObservability apply. | |
| 227 | const readBackendObservabilitySettings = Effect.gen(function* () { | |
| 228 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 229 | const persisted = yield* readPersistedBackendObservabilitySettings; | |
| 230 | return { | |
| 231 | otlpTracesUrl: Option.orElse(environment.otlpTracesUrl, () => persisted.otlpTracesUrl), | |
| 232 | otlpMetricsUrl: Option.orElse(environment.otlpMetricsUrl, () => persisted.otlpMetricsUrl), | |
| 233 | otlpLogsUrl: Option.orElse(environment.otlpLogsUrl, () => persisted.otlpLogsUrl), | |
| 234 | } satisfies BackendObservabilitySettings; | |
| 235 | }); | |
| 236 | ||
| 218 | 237 | interface SharedBootstrapInput { |
| 219 | 238 | readonly bootstrapToken: string; |
| 220 | 239 | readonly observabilitySettings: BackendObservabilitySettings; |
| ... | ... | @@ -488,6 +507,10 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS |
| 488 | 507 | onNone: () => ({}), |
| 489 | 508 | onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), |
| 490 | 509 | }), |
| 510 | ...Option.match(observabilitySettings.otlpLogsUrl, { | |
| 511 | onNone: () => ({}), | |
| 512 | onSome: (otlpLogsUrl) => ({ otlpLogsUrl }), | |
| 513 | }), | |
| 491 | 514 | }); |
| 492 | 515 | |
| 493 | 516 | const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( |
| ... | ... | @@ -804,7 +827,7 @@ export const make = Effect.gen(function* () { |
| 804 | 827 | // restart cycle without having to bounce the desktop process. |
| 805 | 828 | const sharedInputs = Effect.gen(function* () { |
| 806 | 829 | const bootstrapToken = yield* getOrCreateBootstrapToken; |
| 807 | const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( | |
| 830 | const observabilitySettings = yield* readBackendObservabilitySettings.pipe( | |
| 808 | 831 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 809 | 832 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 810 | 833 | ); |
apps/server/src/bin.test.ts+1| ... | ... | @@ -100,6 +100,7 @@ const makeCliTestServerConfig = (baseDir: string) => |
| 100 | 100 | traceMaxFiles: 10, |
| 101 | 101 | otlpTracesUrl: undefined, |
| 102 | 102 | otlpMetricsUrl: undefined, |
| 103 | otlpLogsUrl: undefined, | |
| 103 | 104 | otlpExportIntervalMs: 10_000, |
| 104 | 105 | otlpServiceName: "t3-server", |
| 105 | 106 | otlpHeaders: undefined, |
apps/server/src/cli/config.test.ts+44| ... | ... | @@ -50,6 +50,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 50 | 50 | traceMaxFiles: 10, |
| 51 | 51 | otlpTracesUrl: undefined, |
| 52 | 52 | otlpMetricsUrl: undefined, |
| 53 | otlpLogsUrl: undefined, | |
| 53 | 54 | otlpExportIntervalMs: 10_000, |
| 54 | 55 | otlpServiceName: "t3-server", |
| 55 | 56 | otlpHeaders: undefined, |
| ... | ... | @@ -398,6 +399,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 398 | 399 | tailscaleServePort: 443, |
| 399 | 400 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 400 | 401 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", |
| 402 | otlpLogsUrl: "http://localhost:4318/v1/logs", | |
| 401 | 403 | }), |
| 402 | 404 | ); |
| 403 | 405 | const derivedPaths = yield* deriveServerPaths(baseDir, undefined); |
| ... | ... | @@ -438,6 +440,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 438 | 440 | ...defaultObservabilityConfig, |
| 439 | 441 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 440 | 442 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", |
| 443 | otlpLogsUrl: "http://localhost:4318/v1/logs", | |
| 441 | 444 | mode: "desktop", |
| 442 | 445 | port: 4888, |
| 443 | 446 | cwd: process.cwd(), |
| ... | ... | @@ -604,6 +607,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 604 | 607 | observability: { |
| 605 | 608 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 606 | 609 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", |
| 610 | otlpLogsUrl: "http://localhost:4318/v1/logs", | |
| 607 | 611 | }, |
| 608 | 612 | })}\n`, |
| 609 | 613 | ); |
| ... | ... | @@ -635,11 +639,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 635 | 639 | |
| 636 | 640 | expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); |
| 637 | 641 | expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics"); |
| 642 | expect(resolved.otlpLogsUrl).toBe("http://localhost:4318/v1/logs"); | |
| 638 | 643 | expect(resolved).toEqual({ |
| 639 | 644 | logLevel: "Info", |
| 640 | 645 | ...defaultObservabilityConfig, |
| 641 | 646 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 642 | 647 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", |
| 648 | otlpLogsUrl: "http://localhost:4318/v1/logs", | |
| 643 | 649 | mode: "desktop", |
| 644 | 650 | port: 4888, |
| 645 | 651 | cwd: process.cwd(), |
| ... | ... | @@ -845,4 +851,42 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 845 | 851 | expect(resolved.otlpProtocol).toBe("http/protobuf"); |
| 846 | 852 | }), |
| 847 | 853 | ); |
| 854 | ||
| 855 | it.effect("reads the OTLP logs URL from env", () => | |
| 856 | Effect.gen(function* () { | |
| 857 | const { join } = yield* Path.Path; | |
| 858 | const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-logs-url-base"); | |
| 859 | ||
| 860 | const resolved = yield* resolveServerConfig( | |
| 861 | { | |
| 862 | mode: Option.some("web"), | |
| 863 | port: Option.some(3773), | |
| 864 | host: Option.none(), | |
| 865 | baseDir: Option.some(baseDir), | |
| 866 | cwd: Option.none(), | |
| 867 | devUrl: Option.none(), | |
| 868 | noBrowser: Option.none(), | |
| 869 | bootstrapFd: Option.none(), | |
| 870 | autoBootstrapProjectFromCwd: Option.none(), | |
| 871 | logWebSocketEvents: Option.none(), | |
| 872 | tailscaleServeEnabled: Option.none(), | |
| 873 | tailscaleServePort: Option.none(), | |
| 874 | }, | |
| 875 | Option.none(), | |
| 876 | ).pipe( | |
| 877 | Effect.provide( | |
| 878 | Layer.mergeAll( | |
| 879 | ConfigProvider.layer( | |
| 880 | ConfigProvider.fromEnv({ | |
| 881 | env: { T3CODE_OTLP_LOGS_URL: "http://collector.internal:4318/v1/logs" }, | |
| 882 | }), | |
| 883 | ), | |
| 884 | NetService.layer, | |
| 885 | ), | |
| 886 | ), | |
| 887 | ); | |
| 888 | ||
| 889 | expect(resolved.otlpLogsUrl).toBe("http://collector.internal:4318/v1/logs"); | |
| 890 | }), | |
| 891 | ); | |
| 848 | 892 | }); |
apps/server/src/cli/config.ts+7-1| ... | ... | @@ -96,6 +96,10 @@ const EnvServerConfig = Config.all({ |
| 96 | 96 | Config.option, |
| 97 | 97 | Config.map(Option.getOrUndefined), |
| 98 | 98 | ), |
| 99 | otlpLogsUrl: Config.String("T3CODE_OTLP_LOGS_URL").pipe( | |
| 100 | Config.option, | |
| 101 | Config.map(Option.getOrUndefined), | |
| 102 | ), | |
| 99 | 103 | otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( |
| 100 | 104 | Config.withDefault(10_000), |
| 101 | 105 | ), |
| ... | ... | @@ -227,7 +231,7 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st |
| 227 | 231 | const fs = yield* FileSystem.FileSystem; |
| 228 | 232 | const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false)); |
| 229 | 233 | if (!exists) { |
| 230 | return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; | |
| 234 | return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined }; | |
| 231 | 235 | } |
| 232 | 236 | |
| 233 | 237 | const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); |
| ... | ... | @@ -393,6 +397,8 @@ export const resolveServerConfig = ( |
| 393 | 397 | env.otlpMetricsUrl ?? |
| 394 | 398 | bootstrap?.otlpMetricsUrl ?? |
| 395 | 399 | persistedObservabilitySettings.otlpMetricsUrl, |
| 400 | otlpLogsUrl: | |
| 401 | env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl, | |
| 396 | 402 | otlpExportIntervalMs: env.otlpExportIntervalMs, |
| 397 | 403 | otlpServiceName: env.otlpServiceName, |
| 398 | 404 | otlpHeaders: env.otlpHeaders, |
apps/server/src/cli/pair.ts+1| ... | ... | @@ -320,6 +320,7 @@ const makePairServerConfig = Effect.fn(function* (input: { |
| 320 | 320 | traceMaxFiles: 10, |
| 321 | 321 | otlpTracesUrl: undefined, |
| 322 | 322 | otlpMetricsUrl: undefined, |
| 323 | otlpLogsUrl: undefined, | |
| 323 | 324 | otlpExportIntervalMs: 10_000, |
| 324 | 325 | otlpServiceName: "t3-server", |
| 325 | 326 | otlpHeaders: undefined, |
apps/server/src/config.ts+14| ... | ... | @@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service< |
| 72 | 72 | readonly traceMaxFiles: number; |
| 73 | 73 | readonly otlpTracesUrl: string | undefined; |
| 74 | 74 | readonly otlpMetricsUrl: string | undefined; |
| 75 | readonly otlpLogsUrl: string | undefined; | |
| 75 | 76 | readonly otlpExportIntervalMs: number; |
| 76 | 77 | readonly otlpServiceName: string; |
| 77 | 78 | readonly otlpHeaders: Readonly<Record<string, string>> | undefined; |
| ... | ... | @@ -106,6 +107,18 @@ export class ServerConfig extends Context.Service< |
| 106 | 107 | |
| 107 | 108 | export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config); |
| 108 | 109 | |
| 110 | /** | |
| 111 | * Resource attributes shared by every OTLP exporter, so traces, metrics, and | |
| 112 | * logs report the same service identity to the collector. | |
| 113 | */ | |
| 114 | export const otlpResource = (config: ServerConfig["Service"]) => ({ | |
| 115 | serviceName: config.otlpServiceName, | |
| 116 | attributes: { | |
| 117 | "service.runtime": "t3-server", | |
| 118 | "service.mode": config.mode, | |
| 119 | }, | |
| 120 | }); | |
| 121 | ||
| 109 | 122 | export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerConfig, make(config)); |
| 110 | 123 | |
| 111 | 124 | export const deriveServerPaths = Effect.fn(function* ( |
| ... | ... | @@ -198,6 +211,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( |
| 198 | 211 | traceMaxFiles: 10, |
| 199 | 212 | otlpTracesUrl: undefined, |
| 200 | 213 | otlpMetricsUrl: undefined, |
| 214 | otlpLogsUrl: undefined, | |
| 201 | 215 | otlpExportIntervalMs: 10_000, |
| 202 | 216 | otlpServiceName: "t3-server", |
| 203 | 217 | otlpHeaders: undefined, |
apps/server/src/environment/ServerEnvironment.test.ts+1| ... | ... | @@ -53,6 +53,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { |
| 53 | 53 | traceMaxFiles: 10, |
| 54 | 54 | otlpTracesUrl: undefined, |
| 55 | 55 | otlpMetricsUrl: undefined, |
| 56 | otlpLogsUrl: undefined, | |
| 56 | 57 | otlpExportIntervalMs: 10_000, |
| 57 | 58 | otlpServiceName: "t3-server", |
| 58 | 59 | otlpHeaders: undefined, |
apps/server/src/observability/Layers/Observability.ts+3-14| ... | ... | @@ -21,6 +21,7 @@ export const ObservabilityLive = Layer.unwrap( |
| 21 | 21 | Effect.gen(function* () { |
| 22 | 22 | const config = yield* ServerConfig.ServerConfig; |
| 23 | 23 | const serializationLayer = otlpSerializationLayer(config.otlpProtocol); |
| 24 | const resource = ServerConfig.otlpResource(config); | |
| 24 | 25 | const attribution = yield* ResourceAttribution.ResourceAttribution; |
| 25 | 26 | |
| 26 | 27 | const traceReferencesLayer = Layer.mergeAll( |
| ... | ... | @@ -52,13 +53,7 @@ export const ObservabilityLive = Layer.unwrap( |
| 52 | 53 | url: config.otlpTracesUrl, |
| 53 | 54 | exportInterval: `${config.otlpExportIntervalMs} millis`, |
| 54 | 55 | headers: config.otlpHeaders, |
| 55 | resource: { | |
| 56 | serviceName: config.otlpServiceName, | |
| 57 | attributes: { | |
| 58 | "service.runtime": "t3-server", | |
| 59 | "service.mode": config.mode, | |
| 60 | }, | |
| 61 | }, | |
| 56 | resource, | |
| 62 | 57 | }); |
| 63 | 58 | |
| 64 | 59 | const tracer = yield* makeLocalFileTracer({ |
| ... | ... | @@ -84,13 +79,7 @@ export const ObservabilityLive = Layer.unwrap( |
| 84 | 79 | url: config.otlpMetricsUrl, |
| 85 | 80 | exportInterval: `${config.otlpExportIntervalMs} millis`, |
| 86 | 81 | headers: config.otlpHeaders, |
| 87 | resource: { | |
| 88 | serviceName: config.otlpServiceName, | |
| 89 | attributes: { | |
| 90 | "service.runtime": "t3-server", | |
| 91 | "service.mode": config.mode, | |
| 92 | }, | |
| 93 | }, | |
| 82 | resource, | |
| 94 | 83 | }).pipe(Layer.provideMerge(serializationLayer)); |
| 95 | 84 | |
| 96 | 85 | return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); |
apps/server/src/server.test.ts+4| ... | ... | @@ -580,6 +580,7 @@ const buildAppUnderTest = (options?: { |
| 580 | 580 | traceMaxFiles: 10, |
| 581 | 581 | otlpTracesUrl: undefined, |
| 582 | 582 | otlpMetricsUrl: undefined, |
| 583 | otlpLogsUrl: undefined, | |
| 583 | 584 | otlpExportIntervalMs: 10_000, |
| 584 | 585 | otlpServiceName: "t3-server", |
| 585 | 586 | otlpHeaders: undefined, |
| ... | ... | @@ -6291,6 +6292,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 6291 | 6292 | config: { |
| 6292 | 6293 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 6293 | 6294 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", |
| 6295 | otlpLogsUrl: "http://localhost:4318/v1/logs", | |
| 6294 | 6296 | }, |
| 6295 | 6297 | layers: { |
| 6296 | 6298 | keybindings: { |
| ... | ... | @@ -6326,6 +6328,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 6326 | 6328 | assert.equal(first.config.observability.otlpTracesEnabled, true); |
| 6327 | 6329 | assert.equal(first.config.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics"); |
| 6328 | 6330 | assert.equal(first.config.observability.otlpMetricsEnabled, true); |
| 6331 | assert.equal(first.config.observability.otlpLogsUrl, "http://localhost:4318/v1/logs"); | |
| 6332 | assert.equal(first.config.observability.otlpLogsEnabled, true); | |
| 6329 | 6333 | assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS); |
| 6330 | 6334 | } |
| 6331 | 6335 | assert.deepEqual(second, { |
apps/server/src/serverLogger.test.ts created+197| ... | ... | @@ -0,0 +1,197 @@ |
| 1 | import * as NodePath from "@effect/platform-node/NodePath"; | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import * as Tracer from "effect/Tracer"; | |
| 8 | import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 9 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; | |
| 10 | ||
| 11 | import * as ServerConfig from "./config.ts"; | |
| 12 | import { ServerLoggerLive } from "./serverLogger.ts"; | |
| 13 | ||
| 14 | interface ExportedRequest { | |
| 15 | readonly url: string; | |
| 16 | readonly headers: Readonly<Record<string, string>>; | |
| 17 | readonly body: string; | |
| 18 | } | |
| 19 | ||
| 20 | /** Answers every export with a 200 and keeps what was posted for assertions. */ | |
| 21 | const collectorLayer = (requests: Array<ExportedRequest>) => | |
| 22 | Layer.succeed( | |
| 23 | HttpClient.HttpClient, | |
| 24 | HttpClient.make((request) => | |
| 25 | Effect.sync(() => { | |
| 26 | requests.push({ | |
| 27 | url: request.url, | |
| 28 | headers: request.headers, | |
| 29 | body: | |
| 30 | request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "", | |
| 31 | }); | |
| 32 | return HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })); | |
| 33 | }), | |
| 34 | ), | |
| 35 | ); | |
| 36 | ||
| 37 | const configLayer = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) => | |
| 38 | Layer.effect( | |
| 39 | ServerConfig.ServerConfig, | |
| 40 | Effect.gen(function* () { | |
| 41 | const path = yield* Path.Path; | |
| 42 | const baseDir = path.join(NodeOS.tmpdir(), "t3-server-logger-test"); | |
| 43 | const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); | |
| 44 | return ServerConfig.make({ | |
| 45 | logLevel: "Info", | |
| 46 | traceMinLevel: "Info", | |
| 47 | traceTimingEnabled: false, | |
| 48 | traceBatchWindowMs: 200, | |
| 49 | traceMaxBytes: 1024, | |
| 50 | traceMaxFiles: 1, | |
| 51 | otlpTracesUrl: undefined, | |
| 52 | otlpMetricsUrl: undefined, | |
| 53 | otlpLogsUrl: undefined, | |
| 54 | otlpExportIntervalMs: 10_000, | |
| 55 | otlpServiceName: "t3-server", | |
| 56 | otlpHeaders: undefined, | |
| 57 | otlpProtocol: "http/json", | |
| 58 | cwd: baseDir, | |
| 59 | baseDir, | |
| 60 | ...derivedPaths, | |
| 61 | mode: "web", | |
| 62 | autoBootstrapProjectFromCwd: false, | |
| 63 | logWebSocketEvents: false, | |
| 64 | tailscaleServeEnabled: false, | |
| 65 | tailscaleServePort: 443, | |
| 66 | port: 0, | |
| 67 | host: undefined, | |
| 68 | desktopBootstrapToken: undefined, | |
| 69 | desktopTelemetryFd: undefined, | |
| 70 | desktopTelemetryControlFd: undefined, | |
| 71 | resourceMonitorPath: undefined, | |
| 72 | staticDir: undefined, | |
| 73 | devUrl: undefined, | |
| 74 | devAllowedOrigins: [], | |
| 75 | noBrowser: false, | |
| 76 | startupPresentation: "browser", | |
| 77 | ...overrides, | |
| 78 | }); | |
| 79 | }), | |
| 80 | ).pipe(Layer.provide(NodePath.layer)); | |
| 81 | ||
| 82 | /** | |
| 83 | * Logs once with the server's own logger set installed, then reports what the | |
| 84 | * collector received. The export is asserted after the layer's scope closes, | |
| 85 | * which is where the exporter flushes whatever the interval did not. | |
| 86 | */ | |
| 87 | const logThrough = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) => | |
| 88 | Effect.gen(function* () { | |
| 89 | const requests: Array<ExportedRequest> = []; | |
| 90 | yield* Effect.log("server logger under test").pipe( | |
| 91 | Effect.provide( | |
| 92 | ServerLoggerLive.pipe( | |
| 93 | Layer.provide(configLayer(overrides)), | |
| 94 | Layer.provide(collectorLayer(requests)), | |
| 95 | ), | |
| 96 | ), | |
| 97 | ); | |
| 98 | return requests; | |
| 99 | }); | |
| 100 | ||
| 101 | /** | |
| 102 | * Logs inside a span so the tracer logger has somewhere to attach an event, | |
| 103 | * then reports both what the collector received and what landed on the span. | |
| 104 | */ | |
| 105 | const logInSpanThrough = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) => | |
| 106 | Effect.gen(function* () { | |
| 107 | const requests: Array<ExportedRequest> = []; | |
| 108 | const spans: Array<Tracer.NativeSpan> = []; | |
| 109 | const tracerLayer = Layer.succeed( | |
| 110 | Tracer.Tracer, | |
| 111 | Tracer.make({ | |
| 112 | span: (spanOptions) => { | |
| 113 | const span = new Tracer.NativeSpan(spanOptions); | |
| 114 | spans.push(span); | |
| 115 | return span; | |
| 116 | }, | |
| 117 | }), | |
| 118 | ); | |
| 119 | yield* Effect.log("server logger under test").pipe( | |
| 120 | Effect.withSpan("server-logger-test"), | |
| 121 | Effect.provide( | |
| 122 | Layer.mergeAll( | |
| 123 | ServerLoggerLive.pipe( | |
| 124 | Layer.provide(configLayer(overrides)), | |
| 125 | Layer.provide(collectorLayer(requests)), | |
| 126 | ), | |
| 127 | tracerLayer, | |
| 128 | ), | |
| 129 | ), | |
| 130 | ); | |
| 131 | return { requests, spans }; | |
| 132 | }); | |
| 133 | ||
| 134 | describe("ServerLoggerLive", () => { | |
| 135 | it.effect("exports log records to the configured logs endpoint", () => | |
| 136 | Effect.gen(function* () { | |
| 137 | const requests = yield* logThrough({ | |
| 138 | otlpLogsUrl: "https://collector.example.com/v1/logs", | |
| 139 | }); | |
| 140 | ||
| 141 | assert.lengthOf(requests, 1); | |
| 142 | const [request] = requests; | |
| 143 | assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); | |
| 144 | assert.include(request?.body ?? "", "server logger under test"); | |
| 145 | assert.include(request?.body ?? "", "t3-server"); | |
| 146 | assert.include(request?.body ?? "", "service.runtime"); | |
| 147 | }), | |
| 148 | ); | |
| 149 | ||
| 150 | it.effect("stays off the network when no logs endpoint is configured", () => | |
| 151 | Effect.gen(function* () { | |
| 152 | const requests = yield* logThrough({}); | |
| 153 | ||
| 154 | assert.lengthOf(requests, 0); | |
| 155 | }), | |
| 156 | ); | |
| 157 | ||
| 158 | it.effect("sends the headers and wire format the rest of OTLP export already uses", () => | |
| 159 | Effect.gen(function* () { | |
| 160 | const requests = yield* logThrough({ | |
| 161 | otlpLogsUrl: "https://collector.example.com/v1/logs", | |
| 162 | otlpProtocol: "http/protobuf", | |
| 163 | otlpHeaders: { "x-scope": "logs" }, | |
| 164 | }); | |
| 165 | ||
| 166 | assert.lengthOf(requests, 1); | |
| 167 | assert.strictEqual(requests[0]?.headers["x-scope"], "logs"); | |
| 168 | assert.strictEqual(requests[0]?.headers["content-type"], "application/x-protobuf"); | |
| 169 | }), | |
| 170 | ); | |
| 171 | ||
| 172 | it.effect("attaches log messages to the active span when no logs endpoint is configured", () => | |
| 173 | Effect.gen(function* () { | |
| 174 | const { requests, spans } = yield* logInSpanThrough({}); | |
| 175 | ||
| 176 | assert.lengthOf(requests, 0); | |
| 177 | assert.lengthOf(spans, 1); | |
| 178 | assert.deepEqual( | |
| 179 | spans[0]?.events.map(([name]) => name), | |
| 180 | ["server logger under test"], | |
| 181 | ); | |
| 182 | }), | |
| 183 | ); | |
| 184 | ||
| 185 | it.effect("stops duplicating messages onto the span once log records are exported", () => | |
| 186 | Effect.gen(function* () { | |
| 187 | const { requests, spans } = yield* logInSpanThrough({ | |
| 188 | otlpLogsUrl: "https://collector.example.com/v1/logs", | |
| 189 | }); | |
| 190 | ||
| 191 | assert.lengthOf(requests, 1); | |
| 192 | assert.include(requests[0]?.body ?? "", "server logger under test"); | |
| 193 | assert.lengthOf(spans, 1); | |
| 194 | assert.lengthOf(spans[0]?.events ?? [], 0); | |
| 195 | }), | |
| 196 | ); | |
| 197 | }); |
apps/server/src/serverLogger.ts+37-5| ... | ... | @@ -1,16 +1,48 @@ |
| 1 | import { otlpSerializationLayer } from "@t3tools/shared/observability"; | |
| 1 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | |
| 2 | 4 | import * as Logger from "effect/Logger"; |
| 3 | 5 | import * as References from "effect/References"; |
| 4 | import * as Layer from "effect/Layer"; | |
| 6 | import * as OtlpExporter from "effect/unstable/observability/OtlpExporter"; | |
| 7 | import * as OtlpLogger from "effect/unstable/observability/OtlpLogger"; | |
| 5 | 8 | |
| 6 | import { ServerConfig } from "./config.ts"; | |
| 9 | import { otlpResource, ServerConfig } from "./config.ts"; | |
| 7 | 10 | |
| 8 | 11 | export const ServerLoggerLive = Effect.gen(function* () { |
| 9 | 12 | const config = yield* ServerConfig; |
| 10 | 13 | const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel); |
| 11 | const loggerLayer = Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { | |
| 12 | mergeWithExisting: false, | |
| 13 | }); | |
| 14 | ||
| 15 | const otlpLogger = | |
| 16 | config.otlpLogsUrl === undefined | |
| 17 | ? undefined | |
| 18 | : OtlpLogger.make({ | |
| 19 | url: config.otlpLogsUrl, | |
| 20 | exportInterval: `${config.otlpExportIntervalMs} millis`, | |
| 21 | headers: config.otlpHeaders, | |
| 22 | resource: otlpResource(config), | |
| 23 | }); | |
| 24 | ||
| 25 | // `Logger.layer` writes the whole logger set rather than adding to it, so | |
| 26 | // every logger the server wants has to be named in this one call. | |
| 27 | // | |
| 28 | // `Logger.tracerLogger` reaches a collector by attaching each message to the | |
| 29 | // active span as a span event, which covers only messages logged inside a | |
| 30 | // recorded span and files them under traces. The OTLP logger carries the same | |
| 31 | // messages as log records stamped with their trace and span ids, so it is a | |
| 32 | // superset: keeping both would export every in-span message twice. | |
| 33 | // | |
| 34 | // Recording events on spans is also the shape OpenTelemetry is deprecating, | |
| 35 | // in favor of the log-based events this logger emits: | |
| 36 | // https://opentelemetry.io/blog/2026/deprecating-span-events/ | |
| 37 | const loggerLayer = Logger.layer( | |
| 38 | otlpLogger === undefined | |
| 39 | ? [Logger.consolePretty(), Logger.tracerLogger] | |
| 40 | : [Logger.consolePretty(), otlpLogger], | |
| 41 | { mergeWithExisting: false }, | |
| 42 | ).pipe( | |
| 43 | Layer.provide(OtlpExporter.layerFlusher), | |
| 44 | Layer.provide(otlpSerializationLayer(config.otlpProtocol)), | |
| 45 | ); | |
| 14 | 46 | |
| 15 | 47 | return Layer.mergeAll(loggerLayer, minimumLogLevelLayer); |
| 16 | 48 | }).pipe(Layer.unwrap); |
apps/server/src/serverSettings.test.ts+2| ... | ... | @@ -969,6 +969,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 969 | 969 | observability: { |
| 970 | 970 | otlpTracesUrl: " http://localhost:4318/v1/traces ", |
| 971 | 971 | otlpMetricsUrl: " http://localhost:4318/v1/metrics ", |
| 972 | otlpLogsUrl: " http://localhost:4318/v1/logs ", | |
| 972 | 973 | }, |
| 973 | 974 | }); |
| 974 | 975 | |
| ... | ... | @@ -976,6 +977,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 976 | 977 | assert.deepEqual(next.observability, { |
| 977 | 978 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 978 | 979 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", |
| 980 | otlpLogsUrl: "http://localhost:4318/v1/logs", | |
| 979 | 981 | }); |
| 980 | 982 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 981 | 983 | ); |
apps/server/src/ws.ts+2| ... | ... | @@ -1808,6 +1808,8 @@ const makeWsRpcLayer = ( |
| 1808 | 1808 | ? { otlpMetricsUrl: config.otlpMetricsUrl } |
| 1809 | 1809 | : {}), |
| 1810 | 1810 | otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, |
| 1811 | ...(config.otlpLogsUrl !== undefined ? { otlpLogsUrl: config.otlpLogsUrl } : {}), | |
| 1812 | otlpLogsEnabled: config.otlpLogsUrl !== undefined, | |
| 1811 | 1813 | }, |
| 1812 | 1814 | settings, |
| 1813 | 1815 | shellResumeCompletionMarker: true, |
docs/operations/observability.md+16-3| ... | ... | @@ -6,7 +6,7 @@ T3 Code has one server-side observability model: |
| 6 | 6 | |
| 7 | 7 | - pretty logs go to stdout for humans |
| 8 | 8 | - completed spans go to a local NDJSON trace file |
| 9 | - traces and metrics can also be exported over OTLP to a real backend like Grafana LGTM | |
| 9 | - traces, metrics, and logs can also be exported over OTLP to a real backend like Grafana LGTM | |
| 10 | 10 | |
| 11 | 11 | The local trace file is the persisted source of truth for normal local launches. Those launches do not |
| 12 | 12 | write a separate server log file, but SSH-managed launches also persist the remote process's |
| ... | ... | @@ -22,9 +22,16 @@ Logs are human-facing: |
| 22 | 22 | - format: `Logger.consolePretty()` |
| 23 | 23 | - normal local persistence: none |
| 24 | 24 | - SSH-managed launch persistence: `~/.t3/ssh-launch/<state>/server.log` |
| 25 | - remote export: OTLP only, when configured | |
| 25 | 26 | |
| 26 | 27 | If you want a log message to show up in the trace file, emit it inside an active span with `Effect.log...`. `Logger.tracerLogger` will attach it as a span event. |
| 27 | 28 | |
| 29 | Configuring a logs endpoint takes over that job. The server then exports log records, which cover | |
| 30 | every message instead of only the ones inside an active span and carry the trace and span ids so | |
| 31 | they still line up with the trace. `Logger.tracerLogger` is dropped in that mode, so the same | |
| 32 | message is not exported twice and the trace file stops carrying log messages. stdout output and | |
| 33 | SSH-managed launch persistence stay unchanged either way. | |
| 34 | ||
| 28 | 35 | ### Traces |
| 29 | 36 | |
| 30 | 37 | Completed spans are written as NDJSON records to `serverTracePath`. The default depends on how the |
| ... | ... | @@ -121,6 +128,7 @@ Default Grafana login: |
| 121 | 128 | ```bash |
| 122 | 129 | export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces |
| 123 | 130 | export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics |
| 131 | export T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs | |
| 124 | 132 | export T3CODE_OTLP_SERVICE_NAME=t3-local |
| 125 | 133 | ``` |
| 126 | 134 | |
| ... | ... | @@ -160,6 +168,7 @@ macOS app bundle example: |
| 160 | 168 | ```bash |
| 161 | 169 | T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \ |
| 162 | 170 | T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \ |
| 171 | T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs \ | |
| 163 | 172 | T3CODE_OTLP_SERVICE_NAME=t3-desktop \ |
| 164 | 173 | "/Applications/T3 Code.app/Contents/MacOS/T3 Code" |
| 165 | 174 | ``` |
| ... | ... | @@ -169,6 +178,7 @@ Direct binary example: |
| 169 | 178 | ```bash |
| 170 | 179 | T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \ |
| 171 | 180 | T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \ |
| 181 | T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs \ | |
| 172 | 182 | T3CODE_OTLP_SERVICE_NAME=t3-desktop \ |
| 173 | 183 | ./path/to/your/desktop-app-binary |
| 174 | 184 | ``` |
| ... | ... | @@ -509,6 +519,7 @@ It provides: |
| 509 | 519 | - local NDJSON tracer |
| 510 | 520 | - optional OTLP trace exporter |
| 511 | 521 | - optional OTLP metrics exporter |
| 522 | - optional OTLP log exporter | |
| 512 | 523 | - Effect trace-level and timing refs |
| 513 | 524 | |
| 514 | 525 | ### Env Vars |
| ... | ... | @@ -526,13 +537,15 @@ OTLP export: |
| 526 | 537 | |
| 527 | 538 | - `T3CODE_OTLP_TRACES_URL`: OTLP trace endpoint |
| 528 | 539 | - `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint |
| 540 | - `T3CODE_OTLP_LOGS_URL`: OTLP log endpoint | |
| 529 | 541 | - `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000` |
| 530 | 542 | - `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server` |
| 531 | - `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as | |
| 543 | - `T3CODE_OTLP_HEADERS`: extra headers for all three exporters, same format as | |
| 532 | 544 | `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values. |
| 533 | 545 | - `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf` |
| 534 | 546 | |
| 535 | If the OTLP URLs are unset, local tracing still works and metrics stay in-process only. | |
| 547 | If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay | |
| 548 | on stdout only. | |
| 536 | 549 | |
| 537 | 550 | ### What Is Instrumented Today |
| 538 | 551 |
packages/client-runtime/src/rpc/session.test.ts+1| ... | ... | @@ -149,6 +149,7 @@ const SERVER_CONFIG: ServerConfigType = { |
| 149 | 149 | localTracingEnabled: false, |
| 150 | 150 | otlpTracesEnabled: false, |
| 151 | 151 | otlpMetricsEnabled: false, |
| 152 | otlpLogsEnabled: false, | |
| 152 | 153 | }, |
| 153 | 154 | settings: DEFAULT_SERVER_SETTINGS, |
| 154 | 155 | }; |
packages/contracts/src/desktopBootstrap.ts+1| ... | ... | @@ -16,6 +16,7 @@ export const DesktopBackendBootstrap = Schema.Struct({ |
| 16 | 16 | tailscaleServePort: PortSchema, |
| 17 | 17 | otlpTracesUrl: Schema.optional(Schema.String), |
| 18 | 18 | otlpMetricsUrl: Schema.optional(Schema.String), |
| 19 | otlpLogsUrl: Schema.optional(Schema.String), | |
| 19 | 20 | desktopTelemetryFd: Schema.optionalKey(PositiveInt), |
| 20 | 21 | desktopTelemetryControlFd: Schema.optionalKey(PositiveInt), |
| 21 | 22 | resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), |
packages/contracts/src/server.test.ts+17| ... | ... | @@ -5,6 +5,7 @@ import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 5 | 5 | import { |
| 6 | 6 | resolveEnvironmentMachineKind, |
| 7 | 7 | ServerConfig, |
| 8 | ServerObservability, | |
| 8 | 9 | ServerProvider, |
| 9 | 10 | ServerProviders, |
| 10 | 11 | ServerUpsertKeybindingResult, |
| ... | ... | @@ -13,6 +14,7 @@ import { ServerSettings } from "./settings.ts"; |
| 13 | 14 | |
| 14 | 15 | const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); |
| 15 | 16 | const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders); |
| 17 | const decodeServerObservability = Schema.decodeUnknownSync(ServerObservability); | |
| 16 | 18 | const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); |
| 17 | 19 | const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors); |
| 18 | 20 | |
| ... | ... | @@ -177,6 +179,21 @@ describe("server config forward compatibility", () => { |
| 177 | 179 | }); |
| 178 | 180 | }); |
| 179 | 181 | |
| 182 | describe("ServerObservability", () => { | |
| 183 | it("reads a server from before the log signal as exporting no logs", () => { | |
| 184 | const parsed = decodeServerObservability({ | |
| 185 | logsDirectoryPath: "/tmp/t3/logs", | |
| 186 | localTracingEnabled: true, | |
| 187 | otlpTracesUrl: "https://collector.example.com/v1/traces", | |
| 188 | otlpTracesEnabled: true, | |
| 189 | otlpMetricsEnabled: false, | |
| 190 | }); | |
| 191 | ||
| 192 | expect(parsed.otlpLogsEnabled).toBe(false); | |
| 193 | expect(parsed.otlpLogsUrl).toBeUndefined(); | |
| 194 | }); | |
| 195 | }); | |
| 196 | ||
| 180 | 197 | describe("resolveEnvironmentMachineKind", () => { |
| 181 | 198 | const decodeDescriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor); |
| 182 | 199 | const decodeSettings = Schema.decodeUnknownSync(ServerSettings); |
packages/contracts/src/server.ts+4| ... | ... | @@ -261,6 +261,10 @@ export const ServerObservability = Schema.Struct({ |
| 261 | 261 | otlpTracesEnabled: Schema.Boolean, |
| 262 | 262 | otlpMetricsUrl: Schema.optional(TrimmedNonEmptyString), |
| 263 | 263 | otlpMetricsEnabled: Schema.Boolean, |
| 264 | otlpLogsUrl: Schema.optional(TrimmedNonEmptyString), | |
| 265 | // Absent on servers from before the log signal shipped, so a newer client | |
| 266 | // reads those as having no log export rather than rejecting the whole config. | |
| 267 | otlpLogsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | |
| 264 | 268 | }); |
| 265 | 269 | export type ServerObservability = typeof ServerObservability.Type; |
| 266 | 270 |
packages/contracts/src/settings.ts+2| ... | ... | @@ -889,6 +889,7 @@ export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; |
| 889 | 889 | export const ObservabilitySettings = Schema.Struct({ |
| 890 | 890 | otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 891 | 891 | otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 892 | otlpLogsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 892 | 893 | }); |
| 893 | 894 | export type ObservabilitySettings = typeof ObservabilitySettings.Type; |
| 894 | 895 | |
| ... | ... | @@ -1481,6 +1482,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1481 | 1482 | Schema.Struct({ |
| 1482 | 1483 | otlpTracesUrl: Schema.optionalKey(TrimmedString), |
| 1483 | 1484 | otlpMetricsUrl: Schema.optionalKey(TrimmedString), |
| 1485 | otlpLogsUrl: Schema.optionalKey(TrimmedString), | |
| 1484 | 1486 | }), |
| 1485 | 1487 | ), |
| 1486 | 1488 | providers: Schema.optionalKey( |
packages/shared/src/serverSettings.test.ts+8-1| ... | ... | @@ -224,14 +224,18 @@ describe("serverSettings helpers", () => { |
| 224 | 224 | expect(parsePersistedServerObservabilitySettings("{}")).toEqual({ |
| 225 | 225 | otlpTracesUrl: undefined, |
| 226 | 226 | otlpMetricsUrl: undefined, |
| 227 | otlpLogsUrl: undefined, | |
| 227 | 228 | }); |
| 228 | 229 | expect( |
| 229 | 230 | parsePersistedServerObservabilitySettings( |
| 230 | JSON.stringify({ observability: { otlpTracesUrl: " ", otlpMetricsUrl: "" } }), | |
| 231 | JSON.stringify({ | |
| 232 | observability: { otlpTracesUrl: " ", otlpMetricsUrl: "", otlpLogsUrl: " " }, | |
| 233 | }), | |
| 231 | 234 | ), |
| 232 | 235 | ).toEqual({ |
| 233 | 236 | otlpTracesUrl: undefined, |
| 234 | 237 | otlpMetricsUrl: undefined, |
| 238 | otlpLogsUrl: undefined, | |
| 235 | 239 | }); |
| 236 | 240 | }); |
| 237 | 241 | |
| ... | ... | @@ -242,12 +246,14 @@ describe("serverSettings helpers", () => { |
| 242 | 246 | observability: { |
| 243 | 247 | otlpTracesUrl: " http://localhost:4318/v1/traces ", |
| 244 | 248 | otlpMetricsUrl: " http://localhost:4318/v1/metrics ", |
| 249 | otlpLogsUrl: " http://localhost:4318/v1/logs ", | |
| 245 | 250 | }, |
| 246 | 251 | }), |
| 247 | 252 | ), |
| 248 | 253 | ).toEqual({ |
| 249 | 254 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 250 | 255 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", |
| 256 | otlpLogsUrl: "http://localhost:4318/v1/logs", | |
| 251 | 257 | }); |
| 252 | 258 | }); |
| 253 | 259 | |
| ... | ... | @@ -255,6 +261,7 @@ describe("serverSettings helpers", () => { |
| 255 | 261 | expect(parsePersistedServerObservabilitySettings("{")).toEqual({ |
| 256 | 262 | otlpTracesUrl: undefined, |
| 257 | 263 | otlpMetricsUrl: undefined, |
| 264 | otlpLogsUrl: undefined, | |
| 258 | 265 | }); |
| 259 | 266 | }); |
| 260 | 267 |
packages/shared/src/serverSettings.ts+4-1| ... | ... | @@ -102,6 +102,7 @@ export function resolveSourceControlWriterModelSelection( |
| 102 | 102 | export interface PersistedServerObservabilitySettings { |
| 103 | 103 | readonly otlpTracesUrl: string | undefined; |
| 104 | 104 | readonly otlpMetricsUrl: string | undefined; |
| 105 | readonly otlpLogsUrl: string | undefined; | |
| 105 | 106 | } |
| 106 | 107 | |
| 107 | 108 | function normalizePersistedServerSettingString( |
| ... | ... | @@ -115,11 +116,13 @@ function extractPersistedServerObservabilitySettings(input: { |
| 115 | 116 | readonly observability?: { |
| 116 | 117 | readonly otlpTracesUrl?: string; |
| 117 | 118 | readonly otlpMetricsUrl?: string; |
| 119 | readonly otlpLogsUrl?: string; | |
| 118 | 120 | }; |
| 119 | 121 | }): PersistedServerObservabilitySettings { |
| 120 | 122 | return { |
| 121 | 123 | otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl), |
| 122 | 124 | otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl), |
| 125 | otlpLogsUrl: normalizePersistedServerSettingString(input.observability?.otlpLogsUrl), | |
| 123 | 126 | }; |
| 124 | 127 | } |
| 125 | 128 | |
| ... | ... | @@ -130,7 +133,7 @@ export function parsePersistedServerObservabilitySettings( |
| 130 | 133 | if (Option.isSome(decoded)) { |
| 131 | 134 | return extractPersistedServerObservabilitySettings(decoded.value); |
| 132 | 135 | } |
| 133 | return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; | |
| 136 | return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined }; | |
| 134 | 137 | } |
| 135 | 138 | |
| 136 | 139 | function shouldReplaceTextGenerationModelSelection( |