From 33118d9ab263904e4180b3bf2cbf0a36fe0695e2 Mon Sep 17 00:00:00 2001 From: Arne Bahlo Date: Mon, 14 Sep 2026 19:52:49 +0200 Subject: [PATCH] Add T3CODE_OTLP_PROTOCOL to allow protobuf protocol (#11224) --- apps/desktop/scripts/electron-launcher.mjs | 1 + .../scripts/electron-launcher.test.mjs | 5 + apps/desktop/src/app/DesktopConfig.ts | 5 +- .../src/app/DesktopEnvironment.test.ts | 3 + apps/desktop/src/app/DesktopEnvironment.ts | 3 + apps/desktop/src/app/DesktopObservability.ts | 12 +- .../DesktopBackendConfiguration.test.ts | 6 +- .../backend/DesktopBackendConfiguration.ts | 7 +- apps/server/src/bin.test.ts | 1 + apps/server/src/cli/config.test.ts | 37 ++++++ apps/server/src/cli/config.ts | 6 +- apps/server/src/cli/pair.ts | 1 + apps/server/src/config.ts | 3 + .../src/environment/ServerEnvironment.test.ts | 1 + apps/server/src/http.ts | 6 +- .../src/observability/Layers/Observability.ts | 14 ++- apps/server/src/server.test.ts | 107 ++++++++++++++++++ docs/operations/observability.md | 1 + packages/shared/src/observability.ts | 7 +- 19 files changed, 206 insertions(+), 20 deletions(-) diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index f1c1f57822a923313284db76c182d914ace29ffe..48f66ab76b560f217d80403dfa660680f4ea3708 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -117,6 +117,7 @@ export function makeDevelopmentEnvironmentScript(environment) { ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL], ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS], ["T3CODE_OTLP_HEADERS", environment.T3CODE_OTLP_HEADERS], + ["T3CODE_OTLP_PROTOCOL", environment.T3CODE_OTLP_PROTOCOL], ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); return [ diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 50f5e5f0ac84675b90c0d7de8859abcd0e44c225..84d08cd9b6b898da2fc95edd0233bd6a2a7c9d98 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -21,12 +21,17 @@ describe("electron development launcher", () => { VITE_DEV_SERVER_URL: "http://127.0.0.1:8526", T3CODE_PORT: "16566", T3CODE_HOME: "/tmp/t3", + T3CODE_OTLP_PROTOCOL: "http/protobuf", }); assert.include( environmentScript, "if [ -z \"${VITE_DEV_SERVER_URL:-}\" ]; then export VITE_DEV_SERVER_URL='http://127.0.0.1:8526'; fi", ); + assert.include( + environmentScript, + "if [ -z \"${T3CODE_OTLP_PROTOCOL:-}\" ]; then export T3CODE_OTLP_PROTOCOL='http/protobuf'; fi", + ); assert.notInclude(environmentScript, "\nexport VITE_DEV_SERVER_URL="); }); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 21062d3c0fab01d3095dae40efb2a55eae00898d..924b8ce6e72b6241a2c9c1b6df85b17ce4062c4b 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -1,4 +1,4 @@ -import { OtlpHeadersFromString } from "@t3tools/shared/observability"; +import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Option from "effect/Option"; @@ -50,6 +50,9 @@ export const DesktopConfig = Config.all({ Config.withDefault(10_000), ), otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(Config.option), + otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe( + Config.withDefault("http/json"), + ), appImagePath: trimmedString("APPIMAGE"), disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index e34ae0b00a1c0f24516ec6622a76e39113c16614..0e5fbecd022415faedbed641937d5e1924ef837a 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -53,6 +53,7 @@ describe("DesktopEnvironment", () => { T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", + T3CODE_OTLP_PROTOCOL: "http/protobuf", }, ); @@ -93,6 +94,7 @@ describe("DesktopEnvironment", () => { "x-tenant": "t3", }), ); + assert.equal(environment.otlpProtocol, "http/protobuf"); }), ); @@ -110,6 +112,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); + assert.equal(environment.otlpProtocol, "http/json"); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index f5cfbc0e7cb6477d4a43da6f65208e31e15d1a66..e7a489d5e89d9477faed1b964a4c4b7efb08309f 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -16,6 +16,7 @@ import * as DesktopConfig from "./DesktopConfig.ts"; import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; +import type { OtlpProtocol } from "@t3tools/shared/observability"; export interface MakeDesktopEnvironmentInput { readonly dirname: string; @@ -73,6 +74,7 @@ export class DesktopEnvironment extends Context.Service< readonly otlpTracesUrl: Option.Option; readonly otlpExportIntervalMs: number; readonly otlpHeaders: Option.Option>; + readonly otlpProtocol: OtlpProtocol; readonly branding: DesktopAppBranding; readonly displayName: string; readonly appUserModelId: string; @@ -227,6 +229,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( otlpTracesUrl: config.otlpTracesUrl, otlpExportIntervalMs: config.otlpExportIntervalMs, otlpHeaders: config.otlpHeaders, + otlpProtocol: config.otlpProtocol, branding, displayName, appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 4f9b18d73963a490a7a182ff3d64b606e3529978..19ed351dffb7f33f972e893cfcb731bd13d45a20 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -1,5 +1,9 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; -import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; +import { + makeLocalFileTracer, + makeTraceSink, + otlpSerializationLayer, +} from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -17,7 +21,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as Tracer from "effect/Tracer"; -import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; +import { OtlpExporter, OtlpTracer } from "effect/unstable/observability"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -592,7 +596,7 @@ const tracerLayer = Layer.unwrap( "service.mode": environment.isDevelopment ? "development" : "packaged", }, }, - }); + }).pipe(Effect.provide(otlpSerializationLayer(environment.otlpProtocol))); const tracer = yield* makeLocalFileTracer({ filePath: tracePath, maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, @@ -604,7 +608,7 @@ const tracerLayer = Layer.unwrap( return Layer.succeed(Tracer.Tracer, tracer); }), -).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(OtlpSerialization.layerJson)); +).pipe(Layer.provide(OtlpExporter.layerFlusher)); export const layer = Layer.mergeAll( backendOutputLogFactoryLayer, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 2994c9e2d313ace2b61bd0035b2d264e15453078..595582a1628034ac1858738b27ee379efc711628 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -854,11 +854,13 @@ describe("DesktopBackendConfiguration", () => { const previousOpenAiKey = process.env.OPENAI_API_KEY; const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; + const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; try { process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; process.env.OPENAI_API_KEY = "openai-key"; process.env.ANTHROPIC_API_KEY = "anthropic-key"; process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"'; + process.env.T3CODE_OTLP_PROTOCOL = "http/protobuf"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -878,13 +880,14 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); assert.equal(config.env.OPENAI_API_KEY, "openai-key"); assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); + assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); // The existing WSLENV is preserved byte-for-byte (note the empty // "::" segment survives — WSL ignores it, so we don't normalize // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is // already declared, so it isn't forwarded twice. assert.equal( config.env.WSLENV, - "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS", + "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", ); }).pipe( Effect.provide( @@ -908,6 +911,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("OPENAI_API_KEY", previousOpenAiKey); restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders); + restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 531557d1dac2e7844d2808cddf09731eb432c247..a36902304809de9c30c19acda5fd9af1e35bc5b9 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -87,14 +87,15 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_TAILSCALE_SERVE_PORT", ] as const; -// Sensitive env vars that the WSL backend needs but Windows process.env won't -// forward across the wsl.exe boundary without WSLENV. The dev-server URL is -// handled separately via a `--dev-url` CLI flag because WSLENV translation of +// Env vars that the WSL backend needs but Windows process.env won't forward +// across the wsl.exe boundary without WSLENV. The dev-server URL is handled +// separately via a `--dev-url` CLI flag because WSLENV translation of // URL-shaped values (colons / slashes) is unreliable. const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "T3CODE_OTLP_HEADERS", + "T3CODE_OTLP_PROTOCOL", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index f1e1e3d8d73ca1f46cb4f3a98e67aa953ae3b417..5ba2f281126cd410173cb1033fb01b61674f4680 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -102,6 +102,7 @@ const makeCliTestServerConfig = (baseDir: string) => otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, + otlpProtocol: "http/json", mode: "web", port: 0, host: "127.0.0.1", diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index f68142168bc9bbd44d994b8dbd61e3ebac5c579e..8c4dbbcdbbf23f5efa29cb0f1244889f5f030525 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -53,6 +53,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, + otlpProtocol: "http/json", devAllowedOrigins: [], } as const; @@ -808,4 +809,40 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpTracesUrl).toBe("http://collector.internal:4318"); }), ); + + it.effect("reads the OTLP protocol from env", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-protocol-base"); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { T3CODE_OTLP_PROTOCOL: "http/protobuf" } }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved.otlpProtocol).toBe("http/protobuf"); + }), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 4881b76ce94ec03ae2f9c4aae9a571ec53a6bdda..e0ba4e190f139cdedeb61cf5aab33359187877d5 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,5 +1,5 @@ import * as NetService from "@t3tools/shared/Net"; -import { OtlpHeadersFromString } from "@t3tools/shared/observability"; +import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; @@ -104,6 +104,9 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe( + Config.withDefault("http/json"), + ), mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -393,6 +396,7 @@ export const resolveServerConfig = ( otlpExportIntervalMs: env.otlpExportIntervalMs, otlpServiceName: env.otlpServiceName, otlpHeaders: env.otlpHeaders, + otlpProtocol: env.otlpProtocol, mode, port, cwd, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 353fc9ad598afd08ff742809bfc8d600179eca48..07a4d5fe7896564229da032d22870411fd62913a 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -323,6 +323,7 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, + otlpProtocol: "http/json", mode: "web", port: state.port, host: state.host, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 2f6c522d9627154516b62a8f547efa0684d32e57..919628c8d1edb8acdf88839f8054e9a0586c051d 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -17,6 +17,7 @@ import type * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; +import { OtlpProtocol } from "@t3tools/shared/observability"; export const DEFAULT_PORT = 3773; @@ -74,6 +75,7 @@ export class ServerConfig extends Context.Service< readonly otlpExportIntervalMs: number; readonly otlpServiceName: string; readonly otlpHeaders: Readonly> | undefined; + readonly otlpProtocol: OtlpProtocol; readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; @@ -199,6 +201,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, + otlpProtocol: "http/json", cwd, baseDir, ...derivedPaths, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 2f39e7832e426b965bc7ac1eaff2a7efe97bee95..2577866838b18937ddb74bba6aa739df7ad0940f 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -55,6 +55,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, + otlpProtocol: "http/json", cwd: process.cwd(), baseDir, mode: "web", diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 37494cb60ada03ca977d55e3bd257e02ea61eda8..c564201785522aaafd6fad4edf3aca1478493975 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -16,7 +16,6 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { cast } from "effect/Function"; import { - HttpBody, HttpClient, HttpClientResponse, HttpMiddleware, @@ -26,7 +25,7 @@ import { HttpServerRespondable, } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; -import { OtlpTracer } from "effect/unstable/observability"; +import { OtlpTracer, OtlpSerialization } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; @@ -322,6 +321,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( const otlpHeaders = config.otlpHeaders; const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector; const httpClient = yield* HttpClient.HttpClient; + const serialization = yield* OtlpSerialization.OtlpSerialization; const bodyJson = cast(yield* request.json); yield* Effect.try({ @@ -343,7 +343,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( return yield* httpClient .post(otlpTracesUrl, { - body: HttpBody.jsonUnsafe(bodyJson), + body: serialization.traces(bodyJson), headers: otlpHeaders, }) .pipe( diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 9250d231dfb96705723a7edef04506c19819dcb8..7a7aaf910a9bf6ce426a714159d66bd2c95dc4ae 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -1,12 +1,15 @@ import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; -import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; +import { + makeLocalFileTracer, + makeTraceSink, + otlpSerializationLayer, +} from "@t3tools/shared/observability"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as References from "effect/References"; import * as Tracer from "effect/Tracer"; import * as OtlpExporter from "effect/unstable/observability/OtlpExporter"; import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics"; -import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization"; import * as OtlpTracer from "effect/unstable/observability/OtlpTracer"; import * as ServerConfig from "../../config.ts"; @@ -14,11 +17,10 @@ import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttributio import { ServerLoggerLive } from "../../serverLogger.ts"; import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; -const otlpSerializationLayer = OtlpSerialization.layerJson; - export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const serializationLayer = otlpSerializationLayer(config.otlpProtocol); const attribution = yield* ResourceAttribution.ResourceAttribution; const traceReferencesLayer = Layer.mergeAll( @@ -73,7 +75,7 @@ export const ObservabilityLive = Layer.unwrap( BrowserTraceCollector.layer(sink), ); }), - ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(otlpSerializationLayer)); + ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(serializationLayer)); const metricsLayer = config.otlpMetricsUrl === undefined @@ -89,7 +91,7 @@ export const ObservabilityLive = Layer.unwrap( "service.mode": config.mode, }, }, - }).pipe(Layer.provideMerge(otlpSerializationLayer)); + }).pipe(Layer.provideMerge(serializationLayer)); return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d842cd3d4d21adf07dce9178b1e2d797f372aaa0..d5ba7bc6bf565a8857d5f3c9e35340eda8faa18b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -216,6 +216,7 @@ import { transferBudgetViolations, } from "../integration/TransferBudgetReport.integration.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import { otlpSerializationLayer } from "@t3tools/shared/observability"; const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); @@ -577,6 +578,7 @@ const buildAppUnderTest = (options?: { otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, + otlpProtocol: "http/json", mode: "desktop", port: 0, host: "127.0.0.1", @@ -1060,6 +1062,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.browserTraceCollector, }), ), + Layer.provide(otlpSerializationLayer(config.otlpProtocol)), Layer.provide( Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({ publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), @@ -5216,6 +5219,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("forwards browser OTLP traces as protobuf when the protocol is http/protobuf", () => + Effect.gen(function* () { + const upstreamRequests: Array<{ + readonly body: string; + readonly contentType: string | null; + }> = []; + const localTraceRecords: Array = []; + // Produced by effect's own tracer, so enum fields are numeric and the + // protobuf encoder accepts them. The hand-written payload in the JSON + // test uses enum names, which only the JSON path tolerates. + const payload = yield* makeBrowserOtlpPayload("client.protobuf.test"); + + const collector = yield* Effect.acquireRelease( + Effect.promise(async () => { + const NodeHttp = await import("node:http"); + + return await new Promise<{ + readonly close: () => Promise; + readonly url: string; + }>((resolve, reject) => { + const server = NodeHttp.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + request.on("end", () => { + upstreamRequests.push({ + body: Buffer.concat(chunks).toString("utf8"), + contentType: request.headers["content-type"] ?? null, + }); + response.statusCode = 204; + response.end(); + }); + }); + + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Expected TCP collector address")); + return; + } + + resolve({ + url: `http://127.0.0.1:${address.port}/v1/traces`, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((error) => { + if (error) { + rejectClose(error); + return; + } + resolveClose(); + }); + }), + }); + }); + }); + }), + ({ close }) => Effect.promise(close), + ); + + yield* buildAppUnderTest({ + config: { + otlpTracesUrl: collector.url, + otlpProtocol: "http/protobuf", + }, + layers: { + browserTraceCollector: { + record: (records) => + Effect.sync(() => { + localTraceRecords.push(...records); + }), + }, + }, + }); + + const response = yield* HttpClient.post("/api/observability/v1/traces", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + "content-type": "application/json", + }, + // @effect-diagnostics-next-line preferSchemaOverJson:off + body: HttpBody.text(JSON.stringify(payload), "application/json"), + }); + + assert.equal(response.status, 204); + // The local collector still decodes the browser's JSON before forwarding. + assert.equal(localTraceRecords.length, 1); + assert.equal(upstreamRequests.length, 1); + const forwarded = upstreamRequests[0]; + assert.notEqual(forwarded, undefined); + if (!forwarded) { + return; + } + assert.equal(forwarded.contentType, "application/x-protobuf"); + // Protobuf strings are raw UTF-8, so the span and service names survive + // the stub's utf8 decode even though the surrounding bytes don't. + assert.notEqual(forwarded.body[0], "{"); + assert.include(forwarded.body, "client.protobuf.test"); + assert.include(forwarded.body, "t3-web"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("responds to browser OTLP trace preflight requests with CORS headers", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 287520459c61e629df2062979f69fe8064e1121f..5668ca9d6fbb8a32975d696de8430ecc1049c11d 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -530,6 +530,7 @@ OTLP export: - `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server` - `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values. +- `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf` If the OTLP URLs are unset, local tracing still works and metrics stay in-process only. diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index b17ee253a78d033636e40e2290a78a81cc67cf35..bae865d6537dbf92bb58483a55d121ade96b7c91 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -7,10 +7,15 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; import * as Tracer from "effect/Tracer"; -import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; +import { OtlpResource, OtlpTracer, OtlpSerialization } from "effect/unstable/observability"; import { RotatingFileSink } from "./logging.ts"; +export const OtlpProtocol = Schema.Literals(["http/json", "http/protobuf"]); +export type OtlpProtocol = typeof OtlpProtocol.Type; +export const otlpSerializationLayer = (protocol: OtlpProtocol) => + protocol === "http/protobuf" ? OtlpSerialization.layerProtobuf : OtlpSerialization.layerJson; + const FLUSH_BUFFER_THRESHOLD = 256; const textEncoder = new TextEncoder(); -- 2.54.0