authorgravatar for hey@arne.meArne Bahlo <hey@arne.me> 2026-09-14 19:52:49+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 10:52:49-07:00
log33118d9ab263904e4180b3bf2cbf0a36fe0695e2
tree4f157a735c5f821c58d4ef82181ed4506eab3c5b
parent47ace94962a714a561d7cfbdbaa4c721ef6b0598
signature Signed by PGP key B5690EEEBB952194

Add T3CODE_OTLP_PROTOCOL to allow protobuf protocol (#11224)


19 files changed, 206 insertions(+), 20 deletions(-)

apps/desktop/scripts/electron-launcher.mjs+1
...@@ -117,6 +117,7 @@ export function makeDevelopmentEnvironmentScript(environment) {...@@ -117,6 +117,7 @@ export function makeDevelopmentEnvironmentScript(environment) {
117 ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL],117 ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL],
118 ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS],118 ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS],
119 ["T3CODE_OTLP_HEADERS", environment.T3CODE_OTLP_HEADERS],119 ["T3CODE_OTLP_HEADERS", environment.T3CODE_OTLP_HEADERS],
120 ["T3CODE_OTLP_PROTOCOL", environment.T3CODE_OTLP_PROTOCOL],
120 ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID],121 ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID],
121 ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0);122 ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0);
122 return [123 return [
apps/desktop/scripts/electron-launcher.test.mjs+5
...@@ -21,12 +21,17 @@ describe("electron development launcher", () => {...@@ -21,12 +21,17 @@ describe("electron development launcher", () => {
21 VITE_DEV_SERVER_URL: "http://127.0.0.1:8526",21 VITE_DEV_SERVER_URL: "http://127.0.0.1:8526",
22 T3CODE_PORT: "16566",22 T3CODE_PORT: "16566",
23 T3CODE_HOME: "/tmp/t3",23 T3CODE_HOME: "/tmp/t3",
24 T3CODE_OTLP_PROTOCOL: "http/protobuf",
24 });25 });
2526
26 assert.include(27 assert.include(
27 environmentScript,28 environmentScript,
28 "if [ -z \"${VITE_DEV_SERVER_URL:-}\" ]; then export VITE_DEV_SERVER_URL='http://127.0.0.1:8526'; fi",29 "if [ -z \"${VITE_DEV_SERVER_URL:-}\" ]; then export VITE_DEV_SERVER_URL='http://127.0.0.1:8526'; fi",
29 );30 );
31 assert.include(
32 environmentScript,
33 "if [ -z \"${T3CODE_OTLP_PROTOCOL:-}\" ]; then export T3CODE_OTLP_PROTOCOL='http/protobuf'; fi",
34 );
30 assert.notInclude(environmentScript, "\nexport VITE_DEV_SERVER_URL=");35 assert.notInclude(environmentScript, "\nexport VITE_DEV_SERVER_URL=");
31 });36 });
3237
apps/desktop/src/app/DesktopConfig.ts+4-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { OtlpHeadersFromString } from "@t3tools/shared/observability";1import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability";
2import * as Config from "effect/Config";2import * as Config from "effect/Config";
3import * as ConfigProvider from "effect/ConfigProvider";3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
...@@ -50,6 +50,9 @@ export const DesktopConfig = Config.all({...@@ -50,6 +50,9 @@ export const DesktopConfig = Config.all({
50 Config.withDefault(10_000),50 Config.withDefault(10_000),
51 ),51 ),
52 otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(Config.option),52 otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(Config.option),
53 otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe(
54 Config.withDefault("http/json"),
55 ),
53 appImagePath: trimmedString("APPIMAGE"),56 appImagePath: trimmedString("APPIMAGE"),
54 disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"),57 disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"),
55 mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"),58 mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"),
apps/desktop/src/app/DesktopEnvironment.test.ts+3
...@@ -53,6 +53,7 @@ describe("DesktopEnvironment", () => {...@@ -53,6 +53,7 @@ describe("DesktopEnvironment", () => {
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_EXPORT_INTERVAL_MS: "2500",54 T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500",
55 T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3",55 T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3",
56 T3CODE_OTLP_PROTOCOL: "http/protobuf",
56 },57 },
57 );58 );
5859
...@@ -93,6 +94,7 @@ describe("DesktopEnvironment", () => {...@@ -93,6 +94,7 @@ describe("DesktopEnvironment", () => {
93 "x-tenant": "t3",94 "x-tenant": "t3",
94 }),95 }),
95 );96 );
97 assert.equal(environment.otlpProtocol, "http/protobuf");
96 }),98 }),
97 );99 );
98100
...@@ -110,6 +112,7 @@ describe("DesktopEnvironment", () => {...@@ -110,6 +112,7 @@ describe("DesktopEnvironment", () => {
110 assert.equal(environment.logDir, "/tmp/t3/userdata/logs");112 assert.equal(environment.logDir, "/tmp/t3/userdata/logs");
111 assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");113 assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
112 assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");114 assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");
115 assert.equal(environment.otlpProtocol, "http/json");
113 }),116 }),
114 );117 );
115118
apps/desktop/src/app/DesktopEnvironment.ts+3
...@@ -16,6 +16,7 @@ import * as DesktopConfig from "./DesktopConfig.ts";...@@ -16,6 +16,7 @@ import * as DesktopConfig from "./DesktopConfig.ts";
16import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts";16import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts";
17import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts";17import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts";
18import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";18import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";
19import type { OtlpProtocol } from "@t3tools/shared/observability";
1920
20export interface MakeDesktopEnvironmentInput {21export interface MakeDesktopEnvironmentInput {
21 readonly dirname: string;22 readonly dirname: string;
...@@ -73,6 +74,7 @@ export class DesktopEnvironment extends Context.Service<...@@ -73,6 +74,7 @@ export class DesktopEnvironment extends Context.Service<
73 readonly otlpTracesUrl: Option.Option<string>;74 readonly otlpTracesUrl: Option.Option<string>;
74 readonly otlpExportIntervalMs: number;75 readonly otlpExportIntervalMs: number;
75 readonly otlpHeaders: Option.Option<Record<string, string>>;76 readonly otlpHeaders: Option.Option<Record<string, string>>;
77 readonly otlpProtocol: OtlpProtocol;
76 readonly branding: DesktopAppBranding;78 readonly branding: DesktopAppBranding;
77 readonly displayName: string;79 readonly displayName: string;
78 readonly appUserModelId: string;80 readonly appUserModelId: string;
...@@ -227,6 +229,7 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -227,6 +229,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
227 otlpTracesUrl: config.otlpTracesUrl,229 otlpTracesUrl: config.otlpTracesUrl,
228 otlpExportIntervalMs: config.otlpExportIntervalMs,230 otlpExportIntervalMs: config.otlpExportIntervalMs,
229 otlpHeaders: config.otlpHeaders,231 otlpHeaders: config.otlpHeaders,
232 otlpProtocol: config.otlpProtocol,
230 branding,233 branding,
231 displayName,234 displayName,
232 appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () =>235 appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () =>
apps/desktop/src/app/DesktopObservability.ts+8-4
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";1import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
2import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";2import {
3 makeLocalFileTracer,
4 makeTraceSink,
5 otlpSerializationLayer,
6} from "@t3tools/shared/observability";
3import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";7import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
4import * as Context from "effect/Context";8import * as Context from "effect/Context";
5import * as DateTime from "effect/DateTime";9import * as DateTime from "effect/DateTime";
...@@ -17,7 +21,7 @@ import * as Scope from "effect/Scope";...@@ -17,7 +21,7 @@ import * as Scope from "effect/Scope";
17import * as Semaphore from "effect/Semaphore";21import * as Semaphore from "effect/Semaphore";
18import * as SynchronizedRef from "effect/SynchronizedRef";22import * as SynchronizedRef from "effect/SynchronizedRef";
19import * as Tracer from "effect/Tracer";23import * as Tracer from "effect/Tracer";
20import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";24import { OtlpExporter, OtlpTracer } from "effect/unstable/observability";
2125
22import * as DesktopEnvironment from "./DesktopEnvironment.ts";26import * as DesktopEnvironment from "./DesktopEnvironment.ts";
2327
...@@ -592,7 +596,7 @@ const tracerLayer = Layer.unwrap(...@@ -592,7 +596,7 @@ const tracerLayer = Layer.unwrap(
592 "service.mode": environment.isDevelopment ? "development" : "packaged",596 "service.mode": environment.isDevelopment ? "development" : "packaged",
593 },597 },
594 },598 },
595 });599 }).pipe(Effect.provide(otlpSerializationLayer(environment.otlpProtocol)));
596 const tracer = yield* makeLocalFileTracer({600 const tracer = yield* makeLocalFileTracer({
597 filePath: tracePath,601 filePath: tracePath,
598 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,602 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
...@@ -604,7 +608,7 @@ const tracerLayer = Layer.unwrap(...@@ -604,7 +608,7 @@ const tracerLayer = Layer.unwrap(
604608
605 return Layer.succeed(Tracer.Tracer, tracer);609 return Layer.succeed(Tracer.Tracer, tracer);
606 }),610 }),
607).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(OtlpSerialization.layerJson));611).pipe(Layer.provide(OtlpExporter.layerFlusher));
608612
609export const layer = Layer.mergeAll(613export const layer = Layer.mergeAll(
610 backendOutputLogFactoryLayer,614 backendOutputLogFactoryLayer,
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+5-1
...@@ -854,11 +854,13 @@ describe("DesktopBackendConfiguration", () => {...@@ -854,11 +854,13 @@ describe("DesktopBackendConfiguration", () => {
854 const previousOpenAiKey = process.env.OPENAI_API_KEY;854 const previousOpenAiKey = process.env.OPENAI_API_KEY;
855 const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;855 const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;
856 const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS;856 const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS;
857 const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL;
857 try {858 try {
858 process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u";859 process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u";
859 process.env.OPENAI_API_KEY = "openai-key";860 process.env.OPENAI_API_KEY = "openai-key";
860 process.env.ANTHROPIC_API_KEY = "anthropic-key";861 process.env.ANTHROPIC_API_KEY = "anthropic-key";
861 process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"';862 process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"';
863 process.env.T3CODE_OTLP_PROTOCOL = "http/protobuf";
862864
863 yield* Effect.gen(function* () {865 yield* Effect.gen(function* () {
864 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;866 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
...@@ -878,13 +880,14 @@ describe("DesktopBackendConfiguration", () => {...@@ -878,13 +880,14 @@ describe("DesktopBackendConfiguration", () => {
878 assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/");880 assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/");
879 assert.equal(config.env.OPENAI_API_KEY, "openai-key");881 assert.equal(config.env.OPENAI_API_KEY, "openai-key");
880 assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key");882 assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key");
883 assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf");
881 // The existing WSLENV is preserved byte-for-byte (note the empty884 // The existing WSLENV is preserved byte-for-byte (note the empty
882 // "::" segment survives — WSL ignores it, so we don't normalize885 // "::" segment survives — WSL ignores it, so we don't normalize
883 // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is886 // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is
884 // already declared, so it isn't forwarded twice.887 // already declared, so it isn't forwarded twice.
885 assert.equal(888 assert.equal(
886 config.env.WSLENV,889 config.env.WSLENV,
887 "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS",890 "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL",
888 );891 );
889 }).pipe(892 }).pipe(
890 Effect.provide(893 Effect.provide(
...@@ -908,6 +911,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -908,6 +911,7 @@ describe("DesktopBackendConfiguration", () => {
908 restoreEnv("OPENAI_API_KEY", previousOpenAiKey);911 restoreEnv("OPENAI_API_KEY", previousOpenAiKey);
909 restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey);912 restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey);
910 restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders);913 restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders);
914 restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol);
911 }915 }
912 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),916 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
913 );917 );
apps/desktop/src/backend/DesktopBackendConfiguration.ts+4-3
...@@ -87,14 +87,15 @@ const DESKTOP_BACKEND_ENV_NAMES = [...@@ -87,14 +87,15 @@ const DESKTOP_BACKEND_ENV_NAMES = [
87 "T3CODE_TAILSCALE_SERVE_PORT",87 "T3CODE_TAILSCALE_SERVE_PORT",
88] as const;88] as const;
8989
90// Sensitive env vars that the WSL backend needs but Windows process.env won't90// Env vars that the WSL backend needs but Windows process.env won't forward
91// forward across the wsl.exe boundary without WSLENV. The dev-server URL is91// across the wsl.exe boundary without WSLENV. The dev-server URL is handled
92// handled separately via a `--dev-url` CLI flag because WSLENV translation of92// separately via a `--dev-url` CLI flag because WSLENV translation of
93// URL-shaped values (colons / slashes) is unreliable.93// URL-shaped values (colons / slashes) is unreliable.
94const WSL_FORWARDED_ENV_NAMES = [94const WSL_FORWARDED_ENV_NAMES = [
95 "OPENAI_API_KEY",95 "OPENAI_API_KEY",
96 "ANTHROPIC_API_KEY",96 "ANTHROPIC_API_KEY",
97 "T3CODE_OTLP_HEADERS",97 "T3CODE_OTLP_HEADERS",
98 "T3CODE_OTLP_PROTOCOL",
98] as const;99] as const;
99100
100const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";101const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
apps/server/src/bin.test.ts+1
...@@ -102,6 +102,7 @@ const makeCliTestServerConfig = (baseDir: string) =>...@@ -102,6 +102,7 @@ const makeCliTestServerConfig = (baseDir: string) =>
102 otlpExportIntervalMs: 10_000,102 otlpExportIntervalMs: 10_000,
103 otlpServiceName: "t3-server",103 otlpServiceName: "t3-server",
104 otlpHeaders: undefined,104 otlpHeaders: undefined,
105 otlpProtocol: "http/json",
105 mode: "web",106 mode: "web",
106 port: 0,107 port: 0,
107 host: "127.0.0.1",108 host: "127.0.0.1",
apps/server/src/cli/config.test.ts+37
...@@ -53,6 +53,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -53,6 +53,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
53 otlpExportIntervalMs: 10_000,53 otlpExportIntervalMs: 10_000,
54 otlpServiceName: "t3-server",54 otlpServiceName: "t3-server",
55 otlpHeaders: undefined,55 otlpHeaders: undefined,
56 otlpProtocol: "http/json",
56 devAllowedOrigins: [],57 devAllowedOrigins: [],
57 } as const;58 } as const;
5859
...@@ -808,4 +809,40 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -808,4 +809,40 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
808 expect(resolved.otlpTracesUrl).toBe("http://collector.internal:4318");809 expect(resolved.otlpTracesUrl).toBe("http://collector.internal:4318");
809 }),810 }),
810 );811 );
812
813 it.effect("reads the OTLP protocol from env", () =>
814 Effect.gen(function* () {
815 const { join } = yield* Path.Path;
816 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-protocol-base");
817
818 const resolved = yield* resolveServerConfig(
819 {
820 mode: Option.some("web"),
821 port: Option.some(3773),
822 host: Option.none(),
823 baseDir: Option.some(baseDir),
824 cwd: Option.none(),
825 devUrl: Option.none(),
826 noBrowser: Option.none(),
827 bootstrapFd: Option.none(),
828 autoBootstrapProjectFromCwd: Option.none(),
829 logWebSocketEvents: Option.none(),
830 tailscaleServeEnabled: Option.none(),
831 tailscaleServePort: Option.none(),
832 },
833 Option.none(),
834 ).pipe(
835 Effect.provide(
836 Layer.mergeAll(
837 ConfigProvider.layer(
838 ConfigProvider.fromEnv({ env: { T3CODE_OTLP_PROTOCOL: "http/protobuf" } }),
839 ),
840 NetService.layer,
841 ),
842 ),
843 );
844
845 expect(resolved.otlpProtocol).toBe("http/protobuf");
846 }),
847 );
811});848});
apps/server/src/cli/config.ts+5-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import * as NetService from "@t3tools/shared/Net";1import * as NetService from "@t3tools/shared/Net";
2import { OtlpHeadersFromString } from "@t3tools/shared/observability";2import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability";
3import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";3import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
4import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";4import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";
5import * as Config from "effect/Config";5import * as Config from "effect/Config";
...@@ -104,6 +104,9 @@ const EnvServerConfig = Config.all({...@@ -104,6 +104,9 @@ const EnvServerConfig = Config.all({
104 Config.option,104 Config.option,
105 Config.map(Option.getOrUndefined),105 Config.map(Option.getOrUndefined),
106 ),106 ),
107 otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe(
108 Config.withDefault("http/json"),
109 ),
107 mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe(110 mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe(
108 Config.option,111 Config.option,
109 Config.map(Option.getOrUndefined),112 Config.map(Option.getOrUndefined),
...@@ -393,6 +396,7 @@ export const resolveServerConfig = (...@@ -393,6 +396,7 @@ export const resolveServerConfig = (
393 otlpExportIntervalMs: env.otlpExportIntervalMs,396 otlpExportIntervalMs: env.otlpExportIntervalMs,
394 otlpServiceName: env.otlpServiceName,397 otlpServiceName: env.otlpServiceName,
395 otlpHeaders: env.otlpHeaders,398 otlpHeaders: env.otlpHeaders,
399 otlpProtocol: env.otlpProtocol,
396 mode,400 mode,
397 port,401 port,
398 cwd,402 cwd,
apps/server/src/cli/pair.ts+1
...@@ -323,6 +323,7 @@ const makePairServerConfig = Effect.fn(function* (input: {...@@ -323,6 +323,7 @@ const makePairServerConfig = Effect.fn(function* (input: {
323 otlpExportIntervalMs: 10_000,323 otlpExportIntervalMs: 10_000,
324 otlpServiceName: "t3-server",324 otlpServiceName: "t3-server",
325 otlpHeaders: undefined,325 otlpHeaders: undefined,
326 otlpProtocol: "http/json",
326 mode: "web",327 mode: "web",
327 port: state.port,328 port: state.port,
328 host: state.host,329 host: state.host,
apps/server/src/config.ts+3
...@@ -17,6 +17,7 @@ import type * as Redacted from "effect/Redacted";...@@ -17,6 +17,7 @@ import type * as Redacted from "effect/Redacted";
17import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
1818
19import { sweepStalePendingAttachments } from "./attachmentStore.ts";19import { sweepStalePendingAttachments } from "./attachmentStore.ts";
20import { OtlpProtocol } from "@t3tools/shared/observability";
2021
21export const DEFAULT_PORT = 3773;22export const DEFAULT_PORT = 3773;
2223
...@@ -74,6 +75,7 @@ export class ServerConfig extends Context.Service<...@@ -74,6 +75,7 @@ export class ServerConfig extends Context.Service<
74 readonly otlpExportIntervalMs: number;75 readonly otlpExportIntervalMs: number;
75 readonly otlpServiceName: string;76 readonly otlpServiceName: string;
76 readonly otlpHeaders: Readonly<Record<string, string>> | undefined;77 readonly otlpHeaders: Readonly<Record<string, string>> | undefined;
78 readonly otlpProtocol: OtlpProtocol;
77 readonly mode: RuntimeMode;79 readonly mode: RuntimeMode;
78 readonly port: number;80 readonly port: number;
79 readonly host: string | undefined;81 readonly host: string | undefined;
...@@ -199,6 +201,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (...@@ -199,6 +201,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
199 otlpExportIntervalMs: 10_000,201 otlpExportIntervalMs: 10_000,
200 otlpServiceName: "t3-server",202 otlpServiceName: "t3-server",
201 otlpHeaders: undefined,203 otlpHeaders: undefined,
204 otlpProtocol: "http/json",
202 cwd,205 cwd,
203 baseDir,206 baseDir,
204 ...derivedPaths,207 ...derivedPaths,
apps/server/src/environment/ServerEnvironment.test.ts+1
...@@ -55,6 +55,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {...@@ -55,6 +55,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
55 otlpExportIntervalMs: 10_000,55 otlpExportIntervalMs: 10_000,
56 otlpServiceName: "t3-server",56 otlpServiceName: "t3-server",
57 otlpHeaders: undefined,57 otlpHeaders: undefined,
58 otlpProtocol: "http/json",
58 cwd: process.cwd(),59 cwd: process.cwd(),
59 baseDir,60 baseDir,
60 mode: "web",61 mode: "web",
apps/server/src/http.ts+3-3
...@@ -16,7 +16,6 @@ import * as Schema from "effect/Schema";...@@ -16,7 +16,6 @@ import * as Schema from "effect/Schema";
16import * as Stream from "effect/Stream";16import * as Stream from "effect/Stream";
17import { cast } from "effect/Function";17import { cast } from "effect/Function";
18import {18import {
19 HttpBody,
20 HttpClient,19 HttpClient,
21 HttpClientResponse,20 HttpClientResponse,
22 HttpMiddleware,21 HttpMiddleware,
...@@ -26,7 +25,7 @@ import {...@@ -26,7 +25,7 @@ import {
26 HttpServerRespondable,25 HttpServerRespondable,
27} from "effect/unstable/http";26} from "effect/unstable/http";
28import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";27import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
29import { OtlpTracer } from "effect/unstable/observability";28import { OtlpTracer, OtlpSerialization } from "effect/unstable/observability";
3029
31import * as ServerConfig from "./config.ts";30import * as ServerConfig from "./config.ts";
32import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";31import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
...@@ -322,6 +321,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(...@@ -322,6 +321,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
322 const otlpHeaders = config.otlpHeaders;321 const otlpHeaders = config.otlpHeaders;
323 const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;322 const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;
324 const httpClient = yield* HttpClient.HttpClient;323 const httpClient = yield* HttpClient.HttpClient;
324 const serialization = yield* OtlpSerialization.OtlpSerialization;
325 const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);325 const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);
326326
327 yield* Effect.try({327 yield* Effect.try({
...@@ -343,7 +343,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(...@@ -343,7 +343,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
343343
344 return yield* httpClient344 return yield* httpClient
345 .post(otlpTracesUrl, {345 .post(otlpTracesUrl, {
346 body: HttpBody.jsonUnsafe(bodyJson),346 body: serialization.traces(bodyJson),
347 headers: otlpHeaders,347 headers: otlpHeaders,
348 })348 })
349 .pipe(349 .pipe(
apps/server/src/observability/Layers/Observability.ts+8-6
...@@ -1,12 +1,15 @@...@@ -1,12 +1,15 @@
1import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";1import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
2import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";2import {
3 makeLocalFileTracer,
4 makeTraceSink,
5 otlpSerializationLayer,
6} from "@t3tools/shared/observability";
3import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
5import * as References from "effect/References";9import * as References from "effect/References";
6import * as Tracer from "effect/Tracer";10import * as Tracer from "effect/Tracer";
7import * as OtlpExporter from "effect/unstable/observability/OtlpExporter";11import * as OtlpExporter from "effect/unstable/observability/OtlpExporter";
8import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics";12import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics";
9import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization";
10import * as OtlpTracer from "effect/unstable/observability/OtlpTracer";13import * as OtlpTracer from "effect/unstable/observability/OtlpTracer";
1114
12import * as ServerConfig from "../../config.ts";15import * as ServerConfig from "../../config.ts";
...@@ -14,11 +17,10 @@ import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttributio...@@ -14,11 +17,10 @@ import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttributio
14import { ServerLoggerLive } from "../../serverLogger.ts";17import { ServerLoggerLive } from "../../serverLogger.ts";
15import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";18import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";
1619
17const otlpSerializationLayer = OtlpSerialization.layerJson;
18
19export const ObservabilityLive = Layer.unwrap(20export const ObservabilityLive = Layer.unwrap(
20 Effect.gen(function* () {21 Effect.gen(function* () {
21 const config = yield* ServerConfig.ServerConfig;22 const config = yield* ServerConfig.ServerConfig;
23 const serializationLayer = otlpSerializationLayer(config.otlpProtocol);
22 const attribution = yield* ResourceAttribution.ResourceAttribution;24 const attribution = yield* ResourceAttribution.ResourceAttribution;
2325
24 const traceReferencesLayer = Layer.mergeAll(26 const traceReferencesLayer = Layer.mergeAll(
...@@ -73,7 +75,7 @@ export const ObservabilityLive = Layer.unwrap(...@@ -73,7 +75,7 @@ export const ObservabilityLive = Layer.unwrap(
73 BrowserTraceCollector.layer(sink),75 BrowserTraceCollector.layer(sink),
74 );76 );
75 }),77 }),
76 ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(otlpSerializationLayer));78 ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(serializationLayer));
7779
78 const metricsLayer =80 const metricsLayer =
79 config.otlpMetricsUrl === undefined81 config.otlpMetricsUrl === undefined
...@@ -89,7 +91,7 @@ export const ObservabilityLive = Layer.unwrap(...@@ -89,7 +91,7 @@ export const ObservabilityLive = Layer.unwrap(
89 "service.mode": config.mode,91 "service.mode": config.mode,
90 },92 },
91 },93 },
92 }).pipe(Layer.provideMerge(otlpSerializationLayer));94 }).pipe(Layer.provideMerge(serializationLayer));
9395
94 return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer);96 return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer);
95 }),97 }),
apps/server/src/server.test.ts+107
...@@ -216,6 +216,7 @@ import {...@@ -216,6 +216,7 @@ import {
216 transferBudgetViolations,216 transferBudgetViolations,
217} from "../integration/TransferBudgetReport.integration.ts";217} from "../integration/TransferBudgetReport.integration.ts";
218import { symlinksSupported } from "@t3tools/shared/testing/symlinks";218import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
219import { otlpSerializationLayer } from "@t3tools/shared/observability";
219220
220const defaultProjectId = ProjectId.make("project-default");221const defaultProjectId = ProjectId.make("project-default");
221const defaultThreadId = ThreadId.make("thread-default");222const defaultThreadId = ThreadId.make("thread-default");
...@@ -577,6 +578,7 @@ const buildAppUnderTest = (options?: {...@@ -577,6 +578,7 @@ const buildAppUnderTest = (options?: {
577 otlpExportIntervalMs: 10_000,578 otlpExportIntervalMs: 10_000,
578 otlpServiceName: "t3-server",579 otlpServiceName: "t3-server",
579 otlpHeaders: undefined,580 otlpHeaders: undefined,
581 otlpProtocol: "http/json",
580 mode: "desktop",582 mode: "desktop",
581 port: 0,583 port: 0,
582 host: "127.0.0.1",584 host: "127.0.0.1",
...@@ -1060,6 +1062,7 @@ const buildAppUnderTest = (options?: {...@@ -1060,6 +1062,7 @@ const buildAppUnderTest = (options?: {
1060 ...options?.layers?.browserTraceCollector,1062 ...options?.layers?.browserTraceCollector,
1061 }),1063 }),
1062 ),1064 ),
1065 Layer.provide(otlpSerializationLayer(config.otlpProtocol)),
1063 Layer.provide(1066 Layer.provide(
1064 Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({1067 Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({
1065 publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }),1068 publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }),
...@@ -5216,6 +5219,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -5216,6 +5219,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5216 }).pipe(Effect.provide(NodeHttpServer.layerTest)),5219 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5217 );5220 );
52185221
5222 it.effect("forwards browser OTLP traces as protobuf when the protocol is http/protobuf", () =>
5223 Effect.gen(function* () {
5224 const upstreamRequests: Array<{
5225 readonly body: string;
5226 readonly contentType: string | null;
5227 }> = [];
5228 const localTraceRecords: Array<unknown> = [];
5229 // Produced by effect's own tracer, so enum fields are numeric and the
5230 // protobuf encoder accepts them. The hand-written payload in the JSON
5231 // test uses enum names, which only the JSON path tolerates.
5232 const payload = yield* makeBrowserOtlpPayload("client.protobuf.test");
5233
5234 const collector = yield* Effect.acquireRelease(
5235 Effect.promise(async () => {
5236 const NodeHttp = await import("node:http");
5237
5238 return await new Promise<{
5239 readonly close: () => Promise<void>;
5240 readonly url: string;
5241 }>((resolve, reject) => {
5242 const server = NodeHttp.createServer((request, response) => {
5243 const chunks: Buffer[] = [];
5244 request.on("data", (chunk) => {
5245 chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
5246 });
5247 request.on("end", () => {
5248 upstreamRequests.push({
5249 body: Buffer.concat(chunks).toString("utf8"),
5250 contentType: request.headers["content-type"] ?? null,
5251 });
5252 response.statusCode = 204;
5253 response.end();
5254 });
5255 });
5256
5257 server.on("error", reject);
5258 server.listen(0, "127.0.0.1", () => {
5259 const address = server.address();
5260 if (!address || typeof address === "string") {
5261 reject(new Error("Expected TCP collector address"));
5262 return;
5263 }
5264
5265 resolve({
5266 url: `http://127.0.0.1:${address.port}/v1/traces`,
5267 close: () =>
5268 new Promise<void>((resolveClose, rejectClose) => {
5269 server.close((error) => {
5270 if (error) {
5271 rejectClose(error);
5272 return;
5273 }
5274 resolveClose();
5275 });
5276 }),
5277 });
5278 });
5279 });
5280 }),
5281 ({ close }) => Effect.promise(close),
5282 );
5283
5284 yield* buildAppUnderTest({
5285 config: {
5286 otlpTracesUrl: collector.url,
5287 otlpProtocol: "http/protobuf",
5288 },
5289 layers: {
5290 browserTraceCollector: {
5291 record: (records) =>
5292 Effect.sync(() => {
5293 localTraceRecords.push(...records);
5294 }),
5295 },
5296 },
5297 });
5298
5299 const response = yield* HttpClient.post("/api/observability/v1/traces", {
5300 headers: {
5301 cookie: yield* getAuthenticatedSessionCookieHeader(),
5302 "content-type": "application/json",
5303 },
5304 // @effect-diagnostics-next-line preferSchemaOverJson:off
5305 body: HttpBody.text(JSON.stringify(payload), "application/json"),
5306 });
5307
5308 assert.equal(response.status, 204);
5309 // The local collector still decodes the browser's JSON before forwarding.
5310 assert.equal(localTraceRecords.length, 1);
5311 assert.equal(upstreamRequests.length, 1);
5312 const forwarded = upstreamRequests[0];
5313 assert.notEqual(forwarded, undefined);
5314 if (!forwarded) {
5315 return;
5316 }
5317 assert.equal(forwarded.contentType, "application/x-protobuf");
5318 // Protobuf strings are raw UTF-8, so the span and service names survive
5319 // the stub's utf8 decode even though the surrounding bytes don't.
5320 assert.notEqual(forwarded.body[0], "{");
5321 assert.include(forwarded.body, "client.protobuf.test");
5322 assert.include(forwarded.body, "t3-web");
5323 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5324 );
5325
5219 it.effect("responds to browser OTLP trace preflight requests with CORS headers", () =>5326 it.effect("responds to browser OTLP trace preflight requests with CORS headers", () =>
5220 Effect.gen(function* () {5327 Effect.gen(function* () {
5221 yield* buildAppUnderTest();5328 yield* buildAppUnderTest();
docs/operations/observability.md+1
...@@ -530,6 +530,7 @@ OTLP export:...@@ -530,6 +530,7 @@ OTLP export:
530- `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server`530- `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server`
531- `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as531- `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as
532 `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values.532 `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values.
533- `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf`
533534
534If the OTLP URLs are unset, local tracing still works and metrics stay in-process only.535If the OTLP URLs are unset, local tracing still works and metrics stay in-process only.
535536
packages/shared/src/observability.ts+6-1
...@@ -7,10 +7,15 @@ import * as Schema from "effect/Schema";...@@ -7,10 +7,15 @@ import * as Schema from "effect/Schema";
7import * as SchemaIssue from "effect/SchemaIssue";7import * as SchemaIssue from "effect/SchemaIssue";
8import * as SchemaTransformation from "effect/SchemaTransformation";8import * as SchemaTransformation from "effect/SchemaTransformation";
9import * as Tracer from "effect/Tracer";9import * as Tracer from "effect/Tracer";
10import { OtlpResource, OtlpTracer } from "effect/unstable/observability";10import { OtlpResource, OtlpTracer, OtlpSerialization } from "effect/unstable/observability";
1111
12import { RotatingFileSink } from "./logging.ts";12import { RotatingFileSink } from "./logging.ts";
1313
14export const OtlpProtocol = Schema.Literals(["http/json", "http/protobuf"]);
15export type OtlpProtocol = typeof OtlpProtocol.Type;
16export const otlpSerializationLayer = (protocol: OtlpProtocol) =>
17 protocol === "http/protobuf" ? OtlpSerialization.layerProtobuf : OtlpSerialization.layerJson;
18
14const FLUSH_BUFFER_THRESHOLD = 256;19const FLUSH_BUFFER_THRESHOLD = 256;
15const textEncoder = new TextEncoder();20const textEncoder = new TextEncoder();
1621