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) {
117117 ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL],
118118 ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS],
119119 ["T3CODE_OTLP_HEADERS", environment.T3CODE_OTLP_HEADERS],
120 ["T3CODE_OTLP_PROTOCOL", environment.T3CODE_OTLP_PROTOCOL],
120121 ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID],
121122 ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0);
122123 return [
apps/desktop/scripts/electron-launcher.test.mjs+5
......@@ -21,12 +21,17 @@ describe("electron development launcher", () => {
2121 VITE_DEV_SERVER_URL: "http://127.0.0.1:8526",
2222 T3CODE_PORT: "16566",
2323 T3CODE_HOME: "/tmp/t3",
24 T3CODE_OTLP_PROTOCOL: "http/protobuf",
2425 });
2526
2627 assert.include(
2728 environmentScript,
2829 "if [ -z \"${VITE_DEV_SERVER_URL:-}\" ]; then export VITE_DEV_SERVER_URL='http://127.0.0.1:8526'; fi",
2930 );
31 assert.include(
32 environmentScript,
33 "if [ -z \"${T3CODE_OTLP_PROTOCOL:-}\" ]; then export T3CODE_OTLP_PROTOCOL='http/protobuf'; fi",
34 );
3035 assert.notInclude(environmentScript, "\nexport VITE_DEV_SERVER_URL=");
3136 });
3237
apps/desktop/src/app/DesktopConfig.ts+4-1
......@@ -1,4 +1,4 @@
1import { OtlpHeadersFromString } from "@t3tools/shared/observability";
1import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability";
22import * as Config from "effect/Config";
33import * as ConfigProvider from "effect/ConfigProvider";
44import * as Option from "effect/Option";
......@@ -50,6 +50,9 @@ export const DesktopConfig = Config.all({
5050 Config.withDefault(10_000),
5151 ),
5252 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 ),
5356 appImagePath: trimmedString("APPIMAGE"),
5457 disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"),
5558 mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"),
apps/desktop/src/app/DesktopEnvironment.test.ts+3
......@@ -53,6 +53,7 @@ describe("DesktopEnvironment", () => {
5353 T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ",
5454 T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500",
5555 T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3",
56 T3CODE_OTLP_PROTOCOL: "http/protobuf",
5657 },
5758 );
5859
......@@ -93,6 +94,7 @@ describe("DesktopEnvironment", () => {
9394 "x-tenant": "t3",
9495 }),
9596 );
97 assert.equal(environment.otlpProtocol, "http/protobuf");
9698 }),
9799 );
98100
......@@ -110,6 +112,7 @@ describe("DesktopEnvironment", () => {
110112 assert.equal(environment.logDir, "/tmp/t3/userdata/logs");
111113 assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
112114 assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");
115 assert.equal(environment.otlpProtocol, "http/json");
113116 }),
114117 );
115118
apps/desktop/src/app/DesktopEnvironment.ts+3
......@@ -16,6 +16,7 @@ import * as DesktopConfig from "./DesktopConfig.ts";
1616import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts";
1717import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts";
1818import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";
19import type { OtlpProtocol } from "@t3tools/shared/observability";
1920
2021export interface MakeDesktopEnvironmentInput {
2122 readonly dirname: string;
......@@ -73,6 +74,7 @@ export class DesktopEnvironment extends Context.Service<
7374 readonly otlpTracesUrl: Option.Option<string>;
7475 readonly otlpExportIntervalMs: number;
7576 readonly otlpHeaders: Option.Option<Record<string, string>>;
77 readonly otlpProtocol: OtlpProtocol;
7678 readonly branding: DesktopAppBranding;
7779 readonly displayName: string;
7880 readonly appUserModelId: string;
......@@ -227,6 +229,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
227229 otlpTracesUrl: config.otlpTracesUrl,
228230 otlpExportIntervalMs: config.otlpExportIntervalMs,
229231 otlpHeaders: config.otlpHeaders,
232 otlpProtocol: config.otlpProtocol,
230233 branding,
231234 displayName,
232235 appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () =>
apps/desktop/src/app/DesktopObservability.ts+8-4
......@@ -1,5 +1,9 @@
11import { 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";
37import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
48import * as Context from "effect/Context";
59import * as DateTime from "effect/DateTime";
......@@ -17,7 +21,7 @@ import * as Scope from "effect/Scope";
1721import * as Semaphore from "effect/Semaphore";
1822import * as SynchronizedRef from "effect/SynchronizedRef";
1923import * as Tracer from "effect/Tracer";
20import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
24import { OtlpExporter, OtlpTracer } from "effect/unstable/observability";
2125
2226import * as DesktopEnvironment from "./DesktopEnvironment.ts";
2327
......@@ -592,7 +596,7 @@ const tracerLayer = Layer.unwrap(
592596 "service.mode": environment.isDevelopment ? "development" : "packaged",
593597 },
594598 },
595 });
599 }).pipe(Effect.provide(otlpSerializationLayer(environment.otlpProtocol)));
596600 const tracer = yield* makeLocalFileTracer({
597601 filePath: tracePath,
598602 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
......@@ -604,7 +608,7 @@ const tracerLayer = Layer.unwrap(
604608
605609 return Layer.succeed(Tracer.Tracer, tracer);
606610 }),
607).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(OtlpSerialization.layerJson));
611).pipe(Layer.provide(OtlpExporter.layerFlusher));
608612
609613export const layer = Layer.mergeAll(
610614 backendOutputLogFactoryLayer,
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+5-1
......@@ -854,11 +854,13 @@ describe("DesktopBackendConfiguration", () => {
854854 const previousOpenAiKey = process.env.OPENAI_API_KEY;
855855 const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;
856856 const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS;
857 const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL;
857858 try {
858859 process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u";
859860 process.env.OPENAI_API_KEY = "openai-key";
860861 process.env.ANTHROPIC_API_KEY = "anthropic-key";
861862 process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"';
863 process.env.T3CODE_OTLP_PROTOCOL = "http/protobuf";
862864
863865 yield* Effect.gen(function* () {
864866 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
......@@ -878,13 +880,14 @@ describe("DesktopBackendConfiguration", () => {
878880 assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/");
879881 assert.equal(config.env.OPENAI_API_KEY, "openai-key");
880882 assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key");
883 assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf");
881884 // The existing WSLENV is preserved byte-for-byte (note the empty
882885 // "::" segment survives — WSL ignores it, so we don't normalize
883886 // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is
884887 // already declared, so it isn't forwarded twice.
885888 assert.equal(
886889 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",
888891 );
889892 }).pipe(
890893 Effect.provide(
......@@ -908,6 +911,7 @@ describe("DesktopBackendConfiguration", () => {
908911 restoreEnv("OPENAI_API_KEY", previousOpenAiKey);
909912 restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey);
910913 restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders);
914 restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol);
911915 }
912916 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
913917 );
apps/desktop/src/backend/DesktopBackendConfiguration.ts+4-3
......@@ -87,14 +87,15 @@ const DESKTOP_BACKEND_ENV_NAMES = [
8787 "T3CODE_TAILSCALE_SERVE_PORT",
8888] as const;
8989
90// Sensitive env vars that the WSL backend needs but Windows process.env won't
91// forward across the wsl.exe boundary without WSLENV. The dev-server URL is
92// handled separately via a `--dev-url` CLI flag because WSLENV translation of
90// Env vars that the WSL backend needs but Windows process.env won't forward
91// across the wsl.exe boundary without WSLENV. The dev-server URL is handled
92// separately via a `--dev-url` CLI flag because WSLENV translation of
9393// URL-shaped values (colons / slashes) is unreliable.
9494const WSL_FORWARDED_ENV_NAMES = [
9595 "OPENAI_API_KEY",
9696 "ANTHROPIC_API_KEY",
9797 "T3CODE_OTLP_HEADERS",
98 "T3CODE_OTLP_PROTOCOL",
9899] as const;
99100
100101const 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) =>
102102 otlpExportIntervalMs: 10_000,
103103 otlpServiceName: "t3-server",
104104 otlpHeaders: undefined,
105 otlpProtocol: "http/json",
105106 mode: "web",
106107 port: 0,
107108 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) => {
5353 otlpExportIntervalMs: 10_000,
5454 otlpServiceName: "t3-server",
5555 otlpHeaders: undefined,
56 otlpProtocol: "http/json",
5657 devAllowedOrigins: [],
5758 } as const;
5859
......@@ -808,4 +809,40 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
808809 expect(resolved.otlpTracesUrl).toBe("http://collector.internal:4318");
809810 }),
810811 );
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 );
811848});
apps/server/src/cli/config.ts+5-1
......@@ -1,5 +1,5 @@
11import * as NetService from "@t3tools/shared/Net";
2import { OtlpHeadersFromString } from "@t3tools/shared/observability";
2import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability";
33import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
44import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";
55import * as Config from "effect/Config";
......@@ -104,6 +104,9 @@ const EnvServerConfig = Config.all({
104104 Config.option,
105105 Config.map(Option.getOrUndefined),
106106 ),
107 otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe(
108 Config.withDefault("http/json"),
109 ),
107110 mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe(
108111 Config.option,
109112 Config.map(Option.getOrUndefined),
......@@ -393,6 +396,7 @@ export const resolveServerConfig = (
393396 otlpExportIntervalMs: env.otlpExportIntervalMs,
394397 otlpServiceName: env.otlpServiceName,
395398 otlpHeaders: env.otlpHeaders,
399 otlpProtocol: env.otlpProtocol,
396400 mode,
397401 port,
398402 cwd,
apps/server/src/cli/pair.ts+1
......@@ -323,6 +323,7 @@ const makePairServerConfig = Effect.fn(function* (input: {
323323 otlpExportIntervalMs: 10_000,
324324 otlpServiceName: "t3-server",
325325 otlpHeaders: undefined,
326 otlpProtocol: "http/json",
326327 mode: "web",
327328 port: state.port,
328329 host: state.host,
apps/server/src/config.ts+3
......@@ -17,6 +17,7 @@ import type * as Redacted from "effect/Redacted";
1717import * as Schema from "effect/Schema";
1818
1919import { sweepStalePendingAttachments } from "./attachmentStore.ts";
20import { OtlpProtocol } from "@t3tools/shared/observability";
2021
2122export const DEFAULT_PORT = 3773;
2223
......@@ -74,6 +75,7 @@ export class ServerConfig extends Context.Service<
7475 readonly otlpExportIntervalMs: number;
7576 readonly otlpServiceName: string;
7677 readonly otlpHeaders: Readonly<Record<string, string>> | undefined;
78 readonly otlpProtocol: OtlpProtocol;
7779 readonly mode: RuntimeMode;
7880 readonly port: number;
7981 readonly host: string | undefined;
......@@ -199,6 +201,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
199201 otlpExportIntervalMs: 10_000,
200202 otlpServiceName: "t3-server",
201203 otlpHeaders: undefined,
204 otlpProtocol: "http/json",
202205 cwd,
203206 baseDir,
204207 ...derivedPaths,
apps/server/src/environment/ServerEnvironment.test.ts+1
......@@ -55,6 +55,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
5555 otlpExportIntervalMs: 10_000,
5656 otlpServiceName: "t3-server",
5757 otlpHeaders: undefined,
58 otlpProtocol: "http/json",
5859 cwd: process.cwd(),
5960 baseDir,
6061 mode: "web",
apps/server/src/http.ts+3-3
......@@ -16,7 +16,6 @@ import * as Schema from "effect/Schema";
1616import * as Stream from "effect/Stream";
1717import { cast } from "effect/Function";
1818import {
19 HttpBody,
2019 HttpClient,
2120 HttpClientResponse,
2221 HttpMiddleware,
......@@ -26,7 +25,7 @@ import {
2625 HttpServerRespondable,
2726} from "effect/unstable/http";
2827import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
29import { OtlpTracer } from "effect/unstable/observability";
28import { OtlpTracer, OtlpSerialization } from "effect/unstable/observability";
3029
3130import * as ServerConfig from "./config.ts";
3231import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
......@@ -322,6 +321,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
322321 const otlpHeaders = config.otlpHeaders;
323322 const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;
324323 const httpClient = yield* HttpClient.HttpClient;
324 const serialization = yield* OtlpSerialization.OtlpSerialization;
325325 const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);
326326
327327 yield* Effect.try({
......@@ -343,7 +343,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
343343
344344 return yield* httpClient
345345 .post(otlpTracesUrl, {
346 body: HttpBody.jsonUnsafe(bodyJson),
346 body: serialization.traces(bodyJson),
347347 headers: otlpHeaders,
348348 })
349349 .pipe(
apps/server/src/observability/Layers/Observability.ts+8-6
......@@ -1,12 +1,15 @@
11import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
2import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";
2import {
3 makeLocalFileTracer,
4 makeTraceSink,
5 otlpSerializationLayer,
6} from "@t3tools/shared/observability";
37import * as Effect from "effect/Effect";
48import * as Layer from "effect/Layer";
59import * as References from "effect/References";
610import * as Tracer from "effect/Tracer";
711import * as OtlpExporter from "effect/unstable/observability/OtlpExporter";
812import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics";
9import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization";
1013import * as OtlpTracer from "effect/unstable/observability/OtlpTracer";
1114
1215import * as ServerConfig from "../../config.ts";
......@@ -14,11 +17,10 @@ import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttributio
1417import { ServerLoggerLive } from "../../serverLogger.ts";
1518import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";
1619
17const otlpSerializationLayer = OtlpSerialization.layerJson;
18
1920export const ObservabilityLive = Layer.unwrap(
2021 Effect.gen(function* () {
2122 const config = yield* ServerConfig.ServerConfig;
23 const serializationLayer = otlpSerializationLayer(config.otlpProtocol);
2224 const attribution = yield* ResourceAttribution.ResourceAttribution;
2325
2426 const traceReferencesLayer = Layer.mergeAll(
......@@ -73,7 +75,7 @@ export const ObservabilityLive = Layer.unwrap(
7375 BrowserTraceCollector.layer(sink),
7476 );
7577 }),
76 ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(otlpSerializationLayer));
78 ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(serializationLayer));
7779
7880 const metricsLayer =
7981 config.otlpMetricsUrl === undefined
......@@ -89,7 +91,7 @@ export const ObservabilityLive = Layer.unwrap(
8991 "service.mode": config.mode,
9092 },
9193 },
92 }).pipe(Layer.provideMerge(otlpSerializationLayer));
94 }).pipe(Layer.provideMerge(serializationLayer));
9395
9496 return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer);
9597 }),
apps/server/src/server.test.ts+107
......@@ -216,6 +216,7 @@ import {
216216 transferBudgetViolations,
217217} from "../integration/TransferBudgetReport.integration.ts";
218218import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
219import { otlpSerializationLayer } from "@t3tools/shared/observability";
219220
220221const defaultProjectId = ProjectId.make("project-default");
221222const defaultThreadId = ThreadId.make("thread-default");
......@@ -577,6 +578,7 @@ const buildAppUnderTest = (options?: {
577578 otlpExportIntervalMs: 10_000,
578579 otlpServiceName: "t3-server",
579580 otlpHeaders: undefined,
581 otlpProtocol: "http/json",
580582 mode: "desktop",
581583 port: 0,
582584 host: "127.0.0.1",
......@@ -1060,6 +1062,7 @@ const buildAppUnderTest = (options?: {
10601062 ...options?.layers?.browserTraceCollector,
10611063 }),
10621064 ),
1065 Layer.provide(otlpSerializationLayer(config.otlpProtocol)),
10631066 Layer.provide(
10641067 Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({
10651068 publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }),
......@@ -5216,6 +5219,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
52165219 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
52175220 );
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
52195326 it.effect("responds to browser OTLP trace preflight requests with CORS headers", () =>
52205327 Effect.gen(function* () {
52215328 yield* buildAppUnderTest();
docs/operations/observability.md+1
......@@ -530,6 +530,7 @@ OTLP export:
530530- `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server`
531531- `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as
532532 `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values.
533- `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf`
533534
534535If 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";
77import * as SchemaIssue from "effect/SchemaIssue";
88import * as SchemaTransformation from "effect/SchemaTransformation";
99import * as Tracer from "effect/Tracer";
10import { OtlpResource, OtlpTracer } from "effect/unstable/observability";
10import { OtlpResource, OtlpTracer, OtlpSerialization } from "effect/unstable/observability";
1111
1212import { 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
1419const FLUSH_BUFFER_THRESHOLD = 256;
1520const textEncoder = new TextEncoder();
1621