authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-09-18 18:55:15-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 15:55:15-07:00
log3bb06ad910389fcfc886bd5156f41dc73f307f80
tree73176194566be393263a33233f6b47f1a141c7d4
parent82cd1d1aabe8baa4a5ae23f3c7b08b3fce092e08
signature Signed by PGP key B5690EEEBB952194

feat(server): export log records over OTLP (#12493)

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,6 +46,8 @@ export const DesktopConfig = Config.all({
46 desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"),46 desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"),
47 desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"),47 desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"),
48 otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"),48 otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"),
49 otlpMetricsUrl: trimmedString("T3CODE_OTLP_METRICS_URL"),
50 otlpLogsUrl: trimmedString("T3CODE_OTLP_LOGS_URL"),
49 otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(51 otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
50 Config.withDefault(10_000),52 Config.withDefault(10_000),
51 ),53 ),
apps/desktop/src/app/DesktopEnvironment.test.ts+4
...@@ -51,6 +51,8 @@ describe("DesktopEnvironment", () => {...@@ -51,6 +51,8 @@ describe("DesktopEnvironment", () => {
51 VITE_DEV_SERVER_URL: "http://localhost:5173",51 VITE_DEV_SERVER_URL: "http://localhost:5173",
52 T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ",52 T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ",
53 T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ",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 T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500",56 T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500",
55 T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3",57 T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3",
56 T3CODE_OTLP_PROTOCOL: "http/protobuf",58 T3CODE_OTLP_PROTOCOL: "http/protobuf",
...@@ -86,6 +88,8 @@ describe("DesktopEnvironment", () => {...@@ -86,6 +88,8 @@ describe("DesktopEnvironment", () => {
86 assert.deepEqual(environment.configuredBackendPort, Option.some(4949));88 assert.deepEqual(environment.configuredBackendPort, Option.some(4949));
87 assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef"));89 assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef"));
88 assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces"));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 assert.equal(environment.otlpExportIntervalMs, 2500);93 assert.equal(environment.otlpExportIntervalMs, 2500);
90 assert.deepEqual(94 assert.deepEqual(
91 environment.otlpHeaders,95 environment.otlpHeaders,
apps/desktop/src/app/DesktopEnvironment.ts+4
...@@ -72,6 +72,8 @@ export class DesktopEnvironment extends Context.Service<...@@ -72,6 +72,8 @@ export class DesktopEnvironment extends Context.Service<
72 readonly configuredBackendPort: Option.Option<number>;72 readonly configuredBackendPort: Option.Option<number>;
73 readonly commitHashOverride: Option.Option<string>;73 readonly commitHashOverride: Option.Option<string>;
74 readonly otlpTracesUrl: Option.Option<string>;74 readonly otlpTracesUrl: Option.Option<string>;
75 readonly otlpMetricsUrl: Option.Option<string>;
76 readonly otlpLogsUrl: Option.Option<string>;
75 readonly otlpExportIntervalMs: number;77 readonly otlpExportIntervalMs: number;
76 readonly otlpHeaders: Option.Option<Record<string, string>>;78 readonly otlpHeaders: Option.Option<Record<string, string>>;
77 readonly otlpProtocol: OtlpProtocol;79 readonly otlpProtocol: OtlpProtocol;
...@@ -227,6 +229,8 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -227,6 +229,8 @@ const make = Effect.fn("desktop.environment.make")(function* (
227 configuredBackendPort: config.configuredBackendPort,229 configuredBackendPort: config.configuredBackendPort,
228 commitHashOverride: config.commitHashOverride,230 commitHashOverride: config.commitHashOverride,
229 otlpTracesUrl: config.otlpTracesUrl,231 otlpTracesUrl: config.otlpTracesUrl,
232 otlpMetricsUrl: config.otlpMetricsUrl,
233 otlpLogsUrl: config.otlpLogsUrl,
230 otlpExportIntervalMs: config.otlpExportIntervalMs,234 otlpExportIntervalMs: config.otlpExportIntervalMs,
231 otlpHeaders: config.otlpHeaders,235 otlpHeaders: config.otlpHeaders,
232 otlpProtocol: config.otlpProtocol,236 otlpProtocol: config.otlpProtocol,
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+103
...@@ -23,6 +23,7 @@ const PersistedServerObservabilitySettingsDocument = Schema.Struct({...@@ -23,6 +23,7 @@ const PersistedServerObservabilitySettingsDocument = Schema.Struct({
23 observability: Schema.Struct({23 observability: Schema.Struct({
24 otlpTracesUrl: Schema.String,24 otlpTracesUrl: Schema.String,
25 otlpMetricsUrl: Schema.String,25 otlpMetricsUrl: Schema.String,
26 otlpLogsUrl: Schema.String,
26 }),27 }),
27});28});
2829
...@@ -60,6 +61,9 @@ function makeEnvironmentLayer(...@@ -60,6 +61,9 @@ function makeEnvironmentLayer(
60 readonly resourcesPath?: string;61 readonly resourcesPath?: string;
61 readonly appVersion?: string;62 readonly appVersion?: string;
62 readonly processArch?: NodeJS.Architecture;63 readonly processArch?: NodeJS.Architecture;
64 readonly otlpTracesUrl?: string;
65 readonly otlpMetricsUrl?: string;
66 readonly otlpLogsUrl?: string;
63 },67 },
64) {68) {
65 return DesktopEnvironment.layer({69 return DesktopEnvironment.layer({
...@@ -82,6 +86,9 @@ function makeEnvironmentLayer(...@@ -82,6 +86,9 @@ function makeEnvironmentLayer(
82 T3CODE_MODE: "desktop",86 T3CODE_MODE: "desktop",
83 T3CODE_DESKTOP_LAN_HOST: "192.168.1.50",87 T3CODE_DESKTOP_LAN_HOST: "192.168.1.50",
84 VITE_DEV_SERVER_URL: options?.devServerUrl,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,6 +737,7 @@ describe("DesktopBackendConfiguration", () => {
730 observability: {737 observability: {
731 otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ",738 otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ",
732 otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ",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,6 +745,7 @@ describe("DesktopBackendConfiguration", () => {
737 const config = yield* configuration.resolvePrimary;745 const config = yield* configuration.resolvePrimary;
738 assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces");746 assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces");
739 assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics");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,10 +758,103 @@ describe("DesktopBackendConfiguration", () => {
749758
750 assert.isUndefined(config.bootstrap.otlpTracesUrl);759 assert.isUndefined(config.bootstrap.otlpTracesUrl);
751 assert.isUndefined(config.bootstrap.otlpMetricsUrl);760 assert.isUndefined(config.bootstrap.otlpMetricsUrl);
761 assert.isUndefined(config.bootstrap.otlpLogsUrl);
752 }),762 }),
753 ),763 ),
754 );764 );
755765
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 it.effect("logs structured context when persisted observability settings cannot be read", () =>858 it.effect("logs structured context when persisted observability settings cannot be read", () =>
757 Effect.gen(function* () {859 Effect.gen(function* () {
758 const fileSystem = yield* FileSystem.FileSystem;860 const fileSystem = yield* FileSystem.FileSystem;
...@@ -799,6 +901,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -799,6 +901,7 @@ describe("DesktopBackendConfiguration", () => {
799901
800 assert.isUndefined(config.bootstrap.otlpTracesUrl);902 assert.isUndefined(config.bootstrap.otlpTracesUrl);
801 assert.isUndefined(config.bootstrap.otlpMetricsUrl);903 assert.isUndefined(config.bootstrap.otlpMetricsUrl);
904 assert.isUndefined(config.bootstrap.otlpLogsUrl);
802905
803 const error = messages906 const error = messages
804 .flatMap((message) => (Array.isArray(message) ? message : [message]))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,11 +67,13 @@ export class DesktopBackendConfiguration extends Context.Service<
67interface BackendObservabilitySettings {67interface BackendObservabilitySettings {
68 readonly otlpTracesUrl: Option.Option<string>;68 readonly otlpTracesUrl: Option.Option<string>;
69 readonly otlpMetricsUrl: Option.Option<string>;69 readonly otlpMetricsUrl: Option.Option<string>;
70 readonly otlpLogsUrl: Option.Option<string>;
70}71}
7172
72const emptyBackendObservabilitySettings: BackendObservabilitySettings = {73const emptyBackendObservabilitySettings: BackendObservabilitySettings = {
73 otlpTracesUrl: Option.none(),74 otlpTracesUrl: Option.none(),
74 otlpMetricsUrl: Option.none(),75 otlpMetricsUrl: Option.none(),
76 otlpLogsUrl: Option.none(),
75};77};
7678
77const DESKTOP_BACKEND_ENV_NAMES = [79const DESKTOP_BACKEND_ENV_NAMES = [
...@@ -212,9 +214,26 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () {...@@ -212,9 +214,26 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () {
212 return {214 return {
213 otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl),215 otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl),
214 otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl),216 otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl),
217 otlpLogsUrl: Option.fromNullishOr(parsed.otlpLogsUrl),
215 };218 };
216});219});
217220
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.
227const 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
218interface SharedBootstrapInput {237interface SharedBootstrapInput {
219 readonly bootstrapToken: string;238 readonly bootstrapToken: string;
220 readonly observabilitySettings: BackendObservabilitySettings;239 readonly observabilitySettings: BackendObservabilitySettings;
...@@ -488,6 +507,10 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS...@@ -488,6 +507,10 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS
488 onNone: () => ({}),507 onNone: () => ({}),
489 onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }),508 onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }),
490 }),509 }),
510 ...Option.match(observabilitySettings.otlpLogsUrl, {
511 onNone: () => ({}),
512 onSome: (otlpLogsUrl) => ({ otlpLogsUrl }),
513 }),
491});514});
492515
493const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")(516const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")(
...@@ -804,7 +827,7 @@ export const make = Effect.gen(function* () {...@@ -804,7 +827,7 @@ export const make = Effect.gen(function* () {
804 // restart cycle without having to bounce the desktop process.827 // restart cycle without having to bounce the desktop process.
805 const sharedInputs = Effect.gen(function* () {828 const sharedInputs = Effect.gen(function* () {
806 const bootstrapToken = yield* getOrCreateBootstrapToken;829 const bootstrapToken = yield* getOrCreateBootstrapToken;
807 const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe(830 const observabilitySettings = yield* readBackendObservabilitySettings.pipe(
808 Effect.provideService(FileSystem.FileSystem, fileSystem),831 Effect.provideService(FileSystem.FileSystem, fileSystem),
809 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),832 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
810 );833 );
apps/server/src/bin.test.ts+1
...@@ -100,6 +100,7 @@ const makeCliTestServerConfig = (baseDir: string) =>...@@ -100,6 +100,7 @@ const makeCliTestServerConfig = (baseDir: string) =>
100 traceMaxFiles: 10,100 traceMaxFiles: 10,
101 otlpTracesUrl: undefined,101 otlpTracesUrl: undefined,
102 otlpMetricsUrl: undefined,102 otlpMetricsUrl: undefined,
103 otlpLogsUrl: undefined,
103 otlpExportIntervalMs: 10_000,104 otlpExportIntervalMs: 10_000,
104 otlpServiceName: "t3-server",105 otlpServiceName: "t3-server",
105 otlpHeaders: undefined,106 otlpHeaders: undefined,
apps/server/src/cli/config.test.ts+44
...@@ -50,6 +50,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -50,6 +50,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
50 traceMaxFiles: 10,50 traceMaxFiles: 10,
51 otlpTracesUrl: undefined,51 otlpTracesUrl: undefined,
52 otlpMetricsUrl: undefined,52 otlpMetricsUrl: undefined,
53 otlpLogsUrl: undefined,
53 otlpExportIntervalMs: 10_000,54 otlpExportIntervalMs: 10_000,
54 otlpServiceName: "t3-server",55 otlpServiceName: "t3-server",
55 otlpHeaders: undefined,56 otlpHeaders: undefined,
...@@ -398,6 +399,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -398,6 +399,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
398 tailscaleServePort: 443,399 tailscaleServePort: 443,
399 otlpTracesUrl: "http://localhost:4318/v1/traces",400 otlpTracesUrl: "http://localhost:4318/v1/traces",
400 otlpMetricsUrl: "http://localhost:4318/v1/metrics",401 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
402 otlpLogsUrl: "http://localhost:4318/v1/logs",
401 }),403 }),
402 );404 );
403 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);405 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
...@@ -438,6 +440,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -438,6 +440,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
438 ...defaultObservabilityConfig,440 ...defaultObservabilityConfig,
439 otlpTracesUrl: "http://localhost:4318/v1/traces",441 otlpTracesUrl: "http://localhost:4318/v1/traces",
440 otlpMetricsUrl: "http://localhost:4318/v1/metrics",442 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
443 otlpLogsUrl: "http://localhost:4318/v1/logs",
441 mode: "desktop",444 mode: "desktop",
442 port: 4888,445 port: 4888,
443 cwd: process.cwd(),446 cwd: process.cwd(),
...@@ -604,6 +607,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -604,6 +607,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
604 observability: {607 observability: {
605 otlpTracesUrl: "http://localhost:4318/v1/traces",608 otlpTracesUrl: "http://localhost:4318/v1/traces",
606 otlpMetricsUrl: "http://localhost:4318/v1/metrics",609 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
610 otlpLogsUrl: "http://localhost:4318/v1/logs",
607 },611 },
608 })}\n`,612 })}\n`,
609 );613 );
...@@ -635,11 +639,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -635,11 +639,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
635639
636 expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");640 expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
637 expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics");641 expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics");
642 expect(resolved.otlpLogsUrl).toBe("http://localhost:4318/v1/logs");
638 expect(resolved).toEqual({643 expect(resolved).toEqual({
639 logLevel: "Info",644 logLevel: "Info",
640 ...defaultObservabilityConfig,645 ...defaultObservabilityConfig,
641 otlpTracesUrl: "http://localhost:4318/v1/traces",646 otlpTracesUrl: "http://localhost:4318/v1/traces",
642 otlpMetricsUrl: "http://localhost:4318/v1/metrics",647 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
648 otlpLogsUrl: "http://localhost:4318/v1/logs",
643 mode: "desktop",649 mode: "desktop",
644 port: 4888,650 port: 4888,
645 cwd: process.cwd(),651 cwd: process.cwd(),
...@@ -845,4 +851,42 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -845,4 +851,42 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
845 expect(resolved.otlpProtocol).toBe("http/protobuf");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,6 +96,10 @@ const EnvServerConfig = Config.all({
96 Config.option,96 Config.option,
97 Config.map(Option.getOrUndefined),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 otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(103 otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
100 Config.withDefault(10_000),104 Config.withDefault(10_000),
101 ),105 ),
...@@ -227,7 +231,7 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st...@@ -227,7 +231,7 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st
227 const fs = yield* FileSystem.FileSystem;231 const fs = yield* FileSystem.FileSystem;
228 const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false));232 const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false));
229 if (!exists) {233 if (!exists) {
230 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };234 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined };
231 }235 }
232236
233 const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => ""));237 const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => ""));
...@@ -393,6 +397,8 @@ export const resolveServerConfig = (...@@ -393,6 +397,8 @@ export const resolveServerConfig = (
393 env.otlpMetricsUrl ??397 env.otlpMetricsUrl ??
394 bootstrap?.otlpMetricsUrl ??398 bootstrap?.otlpMetricsUrl ??
395 persistedObservabilitySettings.otlpMetricsUrl,399 persistedObservabilitySettings.otlpMetricsUrl,
400 otlpLogsUrl:
401 env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl,
396 otlpExportIntervalMs: env.otlpExportIntervalMs,402 otlpExportIntervalMs: env.otlpExportIntervalMs,
397 otlpServiceName: env.otlpServiceName,403 otlpServiceName: env.otlpServiceName,
398 otlpHeaders: env.otlpHeaders,404 otlpHeaders: env.otlpHeaders,
apps/server/src/cli/pair.ts+1
...@@ -320,6 +320,7 @@ const makePairServerConfig = Effect.fn(function* (input: {...@@ -320,6 +320,7 @@ const makePairServerConfig = Effect.fn(function* (input: {
320 traceMaxFiles: 10,320 traceMaxFiles: 10,
321 otlpTracesUrl: undefined,321 otlpTracesUrl: undefined,
322 otlpMetricsUrl: undefined,322 otlpMetricsUrl: undefined,
323 otlpLogsUrl: undefined,
323 otlpExportIntervalMs: 10_000,324 otlpExportIntervalMs: 10_000,
324 otlpServiceName: "t3-server",325 otlpServiceName: "t3-server",
325 otlpHeaders: undefined,326 otlpHeaders: undefined,
apps/server/src/config.ts+14
...@@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service<...@@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service<
72 readonly traceMaxFiles: number;72 readonly traceMaxFiles: number;
73 readonly otlpTracesUrl: string | undefined;73 readonly otlpTracesUrl: string | undefined;
74 readonly otlpMetricsUrl: string | undefined;74 readonly otlpMetricsUrl: string | undefined;
75 readonly otlpLogsUrl: string | undefined;
75 readonly otlpExportIntervalMs: number;76 readonly otlpExportIntervalMs: number;
76 readonly otlpServiceName: string;77 readonly otlpServiceName: string;
77 readonly otlpHeaders: Readonly<Record<string, string>> | undefined;78 readonly otlpHeaders: Readonly<Record<string, string>> | undefined;
...@@ -106,6 +107,18 @@ export class ServerConfig extends Context.Service<...@@ -106,6 +107,18 @@ export class ServerConfig extends Context.Service<
106107
107export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config);108export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config);
108109
110/**
111 * Resource attributes shared by every OTLP exporter, so traces, metrics, and
112 * logs report the same service identity to the collector.
113 */
114export const otlpResource = (config: ServerConfig["Service"]) => ({
115 serviceName: config.otlpServiceName,
116 attributes: {
117 "service.runtime": "t3-server",
118 "service.mode": config.mode,
119 },
120});
121
109export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerConfig, make(config));122export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerConfig, make(config));
110123
111export const deriveServerPaths = Effect.fn(function* (124export const deriveServerPaths = Effect.fn(function* (
...@@ -198,6 +211,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (...@@ -198,6 +211,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
198 traceMaxFiles: 10,211 traceMaxFiles: 10,
199 otlpTracesUrl: undefined,212 otlpTracesUrl: undefined,
200 otlpMetricsUrl: undefined,213 otlpMetricsUrl: undefined,
214 otlpLogsUrl: undefined,
201 otlpExportIntervalMs: 10_000,215 otlpExportIntervalMs: 10_000,
202 otlpServiceName: "t3-server",216 otlpServiceName: "t3-server",
203 otlpHeaders: undefined,217 otlpHeaders: undefined,
apps/server/src/environment/ServerEnvironment.test.ts+1
...@@ -53,6 +53,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {...@@ -53,6 +53,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
53 traceMaxFiles: 10,53 traceMaxFiles: 10,
54 otlpTracesUrl: undefined,54 otlpTracesUrl: undefined,
55 otlpMetricsUrl: undefined,55 otlpMetricsUrl: undefined,
56 otlpLogsUrl: undefined,
56 otlpExportIntervalMs: 10_000,57 otlpExportIntervalMs: 10_000,
57 otlpServiceName: "t3-server",58 otlpServiceName: "t3-server",
58 otlpHeaders: undefined,59 otlpHeaders: undefined,
apps/server/src/observability/Layers/Observability.ts+3-14
...@@ -21,6 +21,7 @@ export const ObservabilityLive = Layer.unwrap(...@@ -21,6 +21,7 @@ export 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 const serializationLayer = otlpSerializationLayer(config.otlpProtocol);23 const serializationLayer = otlpSerializationLayer(config.otlpProtocol);
24 const resource = ServerConfig.otlpResource(config);
24 const attribution = yield* ResourceAttribution.ResourceAttribution;25 const attribution = yield* ResourceAttribution.ResourceAttribution;
2526
26 const traceReferencesLayer = Layer.mergeAll(27 const traceReferencesLayer = Layer.mergeAll(
...@@ -52,13 +53,7 @@ export const ObservabilityLive = Layer.unwrap(...@@ -52,13 +53,7 @@ export const ObservabilityLive = Layer.unwrap(
52 url: config.otlpTracesUrl,53 url: config.otlpTracesUrl,
53 exportInterval: `${config.otlpExportIntervalMs} millis`,54 exportInterval: `${config.otlpExportIntervalMs} millis`,
54 headers: config.otlpHeaders,55 headers: config.otlpHeaders,
55 resource: {56 resource,
56 serviceName: config.otlpServiceName,
57 attributes: {
58 "service.runtime": "t3-server",
59 "service.mode": config.mode,
60 },
61 },
62 });57 });
6358
64 const tracer = yield* makeLocalFileTracer({59 const tracer = yield* makeLocalFileTracer({
...@@ -84,13 +79,7 @@ export const ObservabilityLive = Layer.unwrap(...@@ -84,13 +79,7 @@ export const ObservabilityLive = Layer.unwrap(
84 url: config.otlpMetricsUrl,79 url: config.otlpMetricsUrl,
85 exportInterval: `${config.otlpExportIntervalMs} millis`,80 exportInterval: `${config.otlpExportIntervalMs} millis`,
86 headers: config.otlpHeaders,81 headers: config.otlpHeaders,
87 resource: {82 resource,
88 serviceName: config.otlpServiceName,
89 attributes: {
90 "service.runtime": "t3-server",
91 "service.mode": config.mode,
92 },
93 },
94 }).pipe(Layer.provideMerge(serializationLayer));83 }).pipe(Layer.provideMerge(serializationLayer));
9584
96 return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer);85 return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer);
apps/server/src/server.test.ts+4
...@@ -580,6 +580,7 @@ const buildAppUnderTest = (options?: {...@@ -580,6 +580,7 @@ const buildAppUnderTest = (options?: {
580 traceMaxFiles: 10,580 traceMaxFiles: 10,
581 otlpTracesUrl: undefined,581 otlpTracesUrl: undefined,
582 otlpMetricsUrl: undefined,582 otlpMetricsUrl: undefined,
583 otlpLogsUrl: undefined,
583 otlpExportIntervalMs: 10_000,584 otlpExportIntervalMs: 10_000,
584 otlpServiceName: "t3-server",585 otlpServiceName: "t3-server",
585 otlpHeaders: undefined,586 otlpHeaders: undefined,
...@@ -6291,6 +6292,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -6291,6 +6292,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
6291 config: {6292 config: {
6292 otlpTracesUrl: "http://localhost:4318/v1/traces",6293 otlpTracesUrl: "http://localhost:4318/v1/traces",
6293 otlpMetricsUrl: "http://localhost:4318/v1/metrics",6294 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
6295 otlpLogsUrl: "http://localhost:4318/v1/logs",
6294 },6296 },
6295 layers: {6297 layers: {
6296 keybindings: {6298 keybindings: {
...@@ -6326,6 +6328,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -6326,6 +6328,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
6326 assert.equal(first.config.observability.otlpTracesEnabled, true);6328 assert.equal(first.config.observability.otlpTracesEnabled, true);
6327 assert.equal(first.config.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics");6329 assert.equal(first.config.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics");
6328 assert.equal(first.config.observability.otlpMetricsEnabled, true);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 assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS);6333 assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS);
6330 }6334 }
6331 assert.deepEqual(second, {6335 assert.deepEqual(second, {
apps/server/src/serverLogger.test.ts created+197
...@@ -0,0 +1,197 @@
1import * as NodePath from "@effect/platform-node/NodePath";
2import { assert, describe, it } from "@effect/vitest";
3import * as NodeOS from "node:os";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7import * as Tracer from "effect/Tracer";
8import * as HttpClient from "effect/unstable/http/HttpClient";
9import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
10
11import * as ServerConfig from "./config.ts";
12import { ServerLoggerLive } from "./serverLogger.ts";
13
14interface 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. */
21const 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
37const 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 */
87const 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 */
105const 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
134describe("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,16 +1,48 @@
1import { otlpSerializationLayer } from "@t3tools/shared/observability";
1import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
2import * as Logger from "effect/Logger";4import * as Logger from "effect/Logger";
3import * as References from "effect/References";5import * as References from "effect/References";
4import * as Layer from "effect/Layer";6import * as OtlpExporter from "effect/unstable/observability/OtlpExporter";
7import * as OtlpLogger from "effect/unstable/observability/OtlpLogger";
58
6import { ServerConfig } from "./config.ts";9import { otlpResource, ServerConfig } from "./config.ts";
710
8export const ServerLoggerLive = Effect.gen(function* () {11export const ServerLoggerLive = Effect.gen(function* () {
9 const config = yield* ServerConfig;12 const config = yield* ServerConfig;
10 const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel);13 const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel);
11 const loggerLayer = Logger.layer([Logger.consolePretty(), Logger.tracerLogger], {14
12 mergeWithExisting: false,15 const otlpLogger =
13 });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 );
1446
15 return Layer.mergeAll(loggerLayer, minimumLogLevelLayer);47 return Layer.mergeAll(loggerLayer, minimumLogLevelLayer);
16}).pipe(Layer.unwrap);48}).pipe(Layer.unwrap);
apps/server/src/serverSettings.test.ts+2
...@@ -969,6 +969,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -969,6 +969,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
969 observability: {969 observability: {
970 otlpTracesUrl: " http://localhost:4318/v1/traces ",970 otlpTracesUrl: " http://localhost:4318/v1/traces ",
971 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",971 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
972 otlpLogsUrl: " http://localhost:4318/v1/logs ",
972 },973 },
973 });974 });
974975
...@@ -976,6 +977,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -976,6 +977,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
976 assert.deepEqual(next.observability, {977 assert.deepEqual(next.observability, {
977 otlpTracesUrl: "http://localhost:4318/v1/traces",978 otlpTracesUrl: "http://localhost:4318/v1/traces",
978 otlpMetricsUrl: "http://localhost:4318/v1/metrics",979 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
980 otlpLogsUrl: "http://localhost:4318/v1/logs",
979 });981 });
980 }).pipe(Effect.provide(makeServerSettingsLayer())),982 }).pipe(Effect.provide(makeServerSettingsLayer())),
981 );983 );
apps/server/src/ws.ts+2
...@@ -1808,6 +1808,8 @@ const makeWsRpcLayer = (...@@ -1808,6 +1808,8 @@ const makeWsRpcLayer = (
1808 ? { otlpMetricsUrl: config.otlpMetricsUrl }1808 ? { otlpMetricsUrl: config.otlpMetricsUrl }
1809 : {}),1809 : {}),
1810 otlpMetricsEnabled: config.otlpMetricsUrl !== undefined,1810 otlpMetricsEnabled: config.otlpMetricsUrl !== undefined,
1811 ...(config.otlpLogsUrl !== undefined ? { otlpLogsUrl: config.otlpLogsUrl } : {}),
1812 otlpLogsEnabled: config.otlpLogsUrl !== undefined,
1811 },1813 },
1812 settings,1814 settings,
1813 shellResumeCompletionMarker: true,1815 shellResumeCompletionMarker: true,
docs/operations/observability.md+16-3
...@@ -6,7 +6,7 @@ T3 Code has one server-side observability model:...@@ -6,7 +6,7 @@ T3 Code has one server-side observability model:
66
7- pretty logs go to stdout for humans7- pretty logs go to stdout for humans
8- completed spans go to a local NDJSON trace file8- 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 LGTM9- traces, metrics, and logs can also be exported over OTLP to a real backend like Grafana LGTM
1010
11The local trace file is the persisted source of truth for normal local launches. Those launches do not11The local trace file is the persisted source of truth for normal local launches. Those launches do not
12write a separate server log file, but SSH-managed launches also persist the remote process's12write a separate server log file, but SSH-managed launches also persist the remote process's
...@@ -22,9 +22,16 @@ Logs are human-facing:...@@ -22,9 +22,16 @@ Logs are human-facing:
22- format: `Logger.consolePretty()`22- format: `Logger.consolePretty()`
23- normal local persistence: none23- normal local persistence: none
24- SSH-managed launch persistence: `~/.t3/ssh-launch/<state>/server.log`24- SSH-managed launch persistence: `~/.t3/ssh-launch/<state>/server.log`
25- remote export: OTLP only, when configured
2526
26If 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.27If 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.
2728
29Configuring a logs endpoint takes over that job. The server then exports log records, which cover
30every message instead of only the ones inside an active span and carry the trace and span ids so
31they still line up with the trace. `Logger.tracerLogger` is dropped in that mode, so the same
32message is not exported twice and the trace file stops carrying log messages. stdout output and
33SSH-managed launch persistence stay unchanged either way.
34
28### Traces35### Traces
2936
30Completed spans are written as NDJSON records to `serverTracePath`. The default depends on how the37Completed spans are written as NDJSON records to `serverTracePath`. The default depends on how the
...@@ -121,6 +128,7 @@ Default Grafana login:...@@ -121,6 +128,7 @@ Default Grafana login:
121```bash128```bash
122export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces129export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces
123export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics130export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics
131export T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs
124export T3CODE_OTLP_SERVICE_NAME=t3-local132export T3CODE_OTLP_SERVICE_NAME=t3-local
125```133```
126134
...@@ -160,6 +168,7 @@ macOS app bundle example:...@@ -160,6 +168,7 @@ macOS app bundle example:
160```bash168```bash
161T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \169T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \
162T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \170T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \
171T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs \
163T3CODE_OTLP_SERVICE_NAME=t3-desktop \172T3CODE_OTLP_SERVICE_NAME=t3-desktop \
164"/Applications/T3 Code.app/Contents/MacOS/T3 Code"173"/Applications/T3 Code.app/Contents/MacOS/T3 Code"
165```174```
...@@ -169,6 +178,7 @@ Direct binary example:...@@ -169,6 +178,7 @@ Direct binary example:
169```bash178```bash
170T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \179T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \
171T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \180T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \
181T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs \
172T3CODE_OTLP_SERVICE_NAME=t3-desktop \182T3CODE_OTLP_SERVICE_NAME=t3-desktop \
173./path/to/your/desktop-app-binary183./path/to/your/desktop-app-binary
174```184```
...@@ -509,6 +519,7 @@ It provides:...@@ -509,6 +519,7 @@ It provides:
509- local NDJSON tracer519- local NDJSON tracer
510- optional OTLP trace exporter520- optional OTLP trace exporter
511- optional OTLP metrics exporter521- optional OTLP metrics exporter
522- optional OTLP log exporter
512- Effect trace-level and timing refs523- Effect trace-level and timing refs
513524
514### Env Vars525### Env Vars
...@@ -526,13 +537,15 @@ OTLP export:...@@ -526,13 +537,15 @@ OTLP export:
526537
527- `T3CODE_OTLP_TRACES_URL`: OTLP trace endpoint538- `T3CODE_OTLP_TRACES_URL`: OTLP trace endpoint
528- `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint539- `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint
540- `T3CODE_OTLP_LOGS_URL`: OTLP log endpoint
529- `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000`541- `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000`
530- `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server`542- `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server`
531- `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as543- `T3CODE_OTLP_HEADERS`: extra headers for all three exporters, same format as
532 `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values.544 `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values.
533- `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf`545- `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf`
534546
535If the OTLP URLs are unset, local tracing still works and metrics stay in-process only.547If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay
548on stdout only.
536549
537### What Is Instrumented Today550### What Is Instrumented Today
538551
packages/client-runtime/src/rpc/session.test.ts+1
...@@ -149,6 +149,7 @@ const SERVER_CONFIG: ServerConfigType = {...@@ -149,6 +149,7 @@ const SERVER_CONFIG: ServerConfigType = {
149 localTracingEnabled: false,149 localTracingEnabled: false,
150 otlpTracesEnabled: false,150 otlpTracesEnabled: false,
151 otlpMetricsEnabled: false,151 otlpMetricsEnabled: false,
152 otlpLogsEnabled: false,
152 },153 },
153 settings: DEFAULT_SERVER_SETTINGS,154 settings: DEFAULT_SERVER_SETTINGS,
154};155};
packages/contracts/src/desktopBootstrap.ts+1
...@@ -16,6 +16,7 @@ export const DesktopBackendBootstrap = Schema.Struct({...@@ -16,6 +16,7 @@ export const DesktopBackendBootstrap = Schema.Struct({
16 tailscaleServePort: PortSchema,16 tailscaleServePort: PortSchema,
17 otlpTracesUrl: Schema.optional(Schema.String),17 otlpTracesUrl: Schema.optional(Schema.String),
18 otlpMetricsUrl: Schema.optional(Schema.String),18 otlpMetricsUrl: Schema.optional(Schema.String),
19 otlpLogsUrl: Schema.optional(Schema.String),
19 desktopTelemetryFd: Schema.optionalKey(PositiveInt),20 desktopTelemetryFd: Schema.optionalKey(PositiveInt),
20 desktopTelemetryControlFd: Schema.optionalKey(PositiveInt),21 desktopTelemetryControlFd: Schema.optionalKey(PositiveInt),
21 resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString),22 resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString),
packages/contracts/src/server.test.ts+17
...@@ -5,6 +5,7 @@ import { ExecutionEnvironmentDescriptor } from "./environment.ts";...@@ -5,6 +5,7 @@ import { ExecutionEnvironmentDescriptor } from "./environment.ts";
5import {5import {
6 resolveEnvironmentMachineKind,6 resolveEnvironmentMachineKind,
7 ServerConfig,7 ServerConfig,
8 ServerObservability,
8 ServerProvider,9 ServerProvider,
9 ServerProviders,10 ServerProviders,
10 ServerUpsertKeybindingResult,11 ServerUpsertKeybindingResult,
...@@ -13,6 +14,7 @@ import { ServerSettings } from "./settings.ts";...@@ -13,6 +14,7 @@ import { ServerSettings } from "./settings.ts";
1314
14const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);15const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);
15const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders);16const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders);
17const decodeServerObservability = Schema.decodeUnknownSync(ServerObservability);
16const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult);18const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult);
17const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors);19const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors);
1820
...@@ -177,6 +179,21 @@ describe("server config forward compatibility", () => {...@@ -177,6 +179,21 @@ describe("server config forward compatibility", () => {
177 });179 });
178});180});
179181
182describe("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
180describe("resolveEnvironmentMachineKind", () => {197describe("resolveEnvironmentMachineKind", () => {
181 const decodeDescriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor);198 const decodeDescriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor);
182 const decodeSettings = Schema.decodeUnknownSync(ServerSettings);199 const decodeSettings = Schema.decodeUnknownSync(ServerSettings);
packages/contracts/src/server.ts+4
...@@ -261,6 +261,10 @@ export const ServerObservability = Schema.Struct({...@@ -261,6 +261,10 @@ export const ServerObservability = Schema.Struct({
261 otlpTracesEnabled: Schema.Boolean,261 otlpTracesEnabled: Schema.Boolean,
262 otlpMetricsUrl: Schema.optional(TrimmedNonEmptyString),262 otlpMetricsUrl: Schema.optional(TrimmedNonEmptyString),
263 otlpMetricsEnabled: Schema.Boolean,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});
265export type ServerObservability = typeof ServerObservability.Type;269export type ServerObservability = typeof ServerObservability.Type;
266270
packages/contracts/src/settings.ts+2
...@@ -889,6 +889,7 @@ export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;...@@ -889,6 +889,7 @@ export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;
889export const ObservabilitySettings = Schema.Struct({889export const ObservabilitySettings = Schema.Struct({
890 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),890 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
891 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),891 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
892 otlpLogsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
892});893});
893export type ObservabilitySettings = typeof ObservabilitySettings.Type;894export type ObservabilitySettings = typeof ObservabilitySettings.Type;
894895
...@@ -1481,6 +1482,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1481,6 +1482,7 @@ export const ServerSettingsPatch = Schema.Struct({
1481 Schema.Struct({1482 Schema.Struct({
1482 otlpTracesUrl: Schema.optionalKey(TrimmedString),1483 otlpTracesUrl: Schema.optionalKey(TrimmedString),
1483 otlpMetricsUrl: Schema.optionalKey(TrimmedString),1484 otlpMetricsUrl: Schema.optionalKey(TrimmedString),
1485 otlpLogsUrl: Schema.optionalKey(TrimmedString),
1484 }),1486 }),
1485 ),1487 ),
1486 providers: Schema.optionalKey(1488 providers: Schema.optionalKey(
packages/shared/src/serverSettings.test.ts+8-1
...@@ -224,14 +224,18 @@ describe("serverSettings helpers", () => {...@@ -224,14 +224,18 @@ describe("serverSettings helpers", () => {
224 expect(parsePersistedServerObservabilitySettings("{}")).toEqual({224 expect(parsePersistedServerObservabilitySettings("{}")).toEqual({
225 otlpTracesUrl: undefined,225 otlpTracesUrl: undefined,
226 otlpMetricsUrl: undefined,226 otlpMetricsUrl: undefined,
227 otlpLogsUrl: undefined,
227 });228 });
228 expect(229 expect(
229 parsePersistedServerObservabilitySettings(230 parsePersistedServerObservabilitySettings(
230 JSON.stringify({ observability: { otlpTracesUrl: " ", otlpMetricsUrl: "" } }),231 JSON.stringify({
232 observability: { otlpTracesUrl: " ", otlpMetricsUrl: "", otlpLogsUrl: " " },
233 }),
231 ),234 ),
232 ).toEqual({235 ).toEqual({
233 otlpTracesUrl: undefined,236 otlpTracesUrl: undefined,
234 otlpMetricsUrl: undefined,237 otlpMetricsUrl: undefined,
238 otlpLogsUrl: undefined,
235 });239 });
236 });240 });
237241
...@@ -242,12 +246,14 @@ describe("serverSettings helpers", () => {...@@ -242,12 +246,14 @@ describe("serverSettings helpers", () => {
242 observability: {246 observability: {
243 otlpTracesUrl: " http://localhost:4318/v1/traces ",247 otlpTracesUrl: " http://localhost:4318/v1/traces ",
244 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",248 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
249 otlpLogsUrl: " http://localhost:4318/v1/logs ",
245 },250 },
246 }),251 }),
247 ),252 ),
248 ).toEqual({253 ).toEqual({
249 otlpTracesUrl: "http://localhost:4318/v1/traces",254 otlpTracesUrl: "http://localhost:4318/v1/traces",
250 otlpMetricsUrl: "http://localhost:4318/v1/metrics",255 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
256 otlpLogsUrl: "http://localhost:4318/v1/logs",
251 });257 });
252 });258 });
253259
...@@ -255,6 +261,7 @@ describe("serverSettings helpers", () => {...@@ -255,6 +261,7 @@ describe("serverSettings helpers", () => {
255 expect(parsePersistedServerObservabilitySettings("{")).toEqual({261 expect(parsePersistedServerObservabilitySettings("{")).toEqual({
256 otlpTracesUrl: undefined,262 otlpTracesUrl: undefined,
257 otlpMetricsUrl: undefined,263 otlpMetricsUrl: undefined,
264 otlpLogsUrl: undefined,
258 });265 });
259 });266 });
260267
packages/shared/src/serverSettings.ts+4-1
...@@ -102,6 +102,7 @@ export function resolveSourceControlWriterModelSelection(...@@ -102,6 +102,7 @@ export function resolveSourceControlWriterModelSelection(
102export interface PersistedServerObservabilitySettings {102export interface PersistedServerObservabilitySettings {
103 readonly otlpTracesUrl: string | undefined;103 readonly otlpTracesUrl: string | undefined;
104 readonly otlpMetricsUrl: string | undefined;104 readonly otlpMetricsUrl: string | undefined;
105 readonly otlpLogsUrl: string | undefined;
105}106}
106107
107function normalizePersistedServerSettingString(108function normalizePersistedServerSettingString(
...@@ -115,11 +116,13 @@ function extractPersistedServerObservabilitySettings(input: {...@@ -115,11 +116,13 @@ function extractPersistedServerObservabilitySettings(input: {
115 readonly observability?: {116 readonly observability?: {
116 readonly otlpTracesUrl?: string;117 readonly otlpTracesUrl?: string;
117 readonly otlpMetricsUrl?: string;118 readonly otlpMetricsUrl?: string;
119 readonly otlpLogsUrl?: string;
118 };120 };
119}): PersistedServerObservabilitySettings {121}): PersistedServerObservabilitySettings {
120 return {122 return {
121 otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl),123 otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl),
122 otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl),124 otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl),
125 otlpLogsUrl: normalizePersistedServerSettingString(input.observability?.otlpLogsUrl),
123 };126 };
124}127}
125128
...@@ -130,7 +133,7 @@ export function parsePersistedServerObservabilitySettings(...@@ -130,7 +133,7 @@ export function parsePersistedServerObservabilitySettings(
130 if (Option.isSome(decoded)) {133 if (Option.isSome(decoded)) {
131 return extractPersistedServerObservabilitySettings(decoded.value);134 return extractPersistedServerObservabilitySettings(decoded.value);
132 }135 }
133 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };136 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined };
134}137}
135138
136function shouldReplaceTextGenerationModelSelection(139function shouldReplaceTextGenerationModelSelection(