authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-09-20 15:56:26-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 12:56:26-07:00
logfa1e1715538f6f8ac076761f7821f68479f616ab
treee22554ddb368fcd2d2d12a649fa3662ca59afd86
parentc14f6015bfe479d313355cb234af1a5c16dbb15f
signature Signed by PGP key B5690EEEBB952194

refactor(observability): hold OTLP export settings per signal (#12657)

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>

12 files changed, 104 insertions(+), 49 deletions(-)

apps/server/src/bin.test.ts+4-3
......@@ -15,6 +15,7 @@ import {
1515} from "@t3tools/contracts";
1616import * as NetService from "@t3tools/shared/Net";
1717import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
18import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
1819import { assert, it } from "@effect/vitest";
1920import * as Effect from "effect/Effect";
2021import * as DateTime from "effect/DateTime";
......@@ -101,10 +102,10 @@ const makeCliTestServerConfig = (baseDir: string) =>
101102 otlpTracesUrl: undefined,
102103 otlpMetricsUrl: undefined,
103104 otlpLogsUrl: undefined,
104 otlpExportIntervalMs: 10_000,
105 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
106 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
107 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
105108 otlpServiceName: "t3-server",
106 otlpHeaders: undefined,
107 otlpProtocol: "http/json",
108109 mode: "web",
109110 port: 0,
110111 host: "127.0.0.1",
apps/server/src/cli/config.test.ts+12-7
......@@ -17,6 +17,7 @@ import {
1717 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
1818} from "@t3tools/contracts";
1919import * as NetService from "@t3tools/shared/Net";
20import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
2021import * as NodeServices from "@effect/platform-node/NodeServices";
2122import { deriveServerPaths } from "../config.ts";
2223import { resolveServerConfig } from "./config.ts";
......@@ -51,10 +52,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
5152 otlpTracesUrl: undefined,
5253 otlpMetricsUrl: undefined,
5354 otlpLogsUrl: undefined,
54 otlpExportIntervalMs: 10_000,
55 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
56 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
57 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
5558 otlpServiceName: "t3-server",
56 otlpHeaders: undefined,
57 otlpProtocol: "http/json",
5859 devAllowedOrigins: [],
5960 } as const;
6061
......@@ -764,7 +765,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
764765 ),
765766 );
766767
767 expect(resolved.otlpHeaders).toEqual({
768 expect(resolved.otlpTracesExport.headers).toEqual({
768769 authorization: "Basic abc==",
769770 "x-tenant": "t3",
770771 });
......@@ -808,7 +809,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
808809 ),
809810 );
810811
811 expect(resolved.otlpHeaders).toEqual({
812 expect(resolved.otlpTracesExport.headers).toEqual({
812813 authorization: "Bearer abc==",
813814 "x-tenant": "t3",
814815 });
......@@ -816,7 +817,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
816817 }),
817818 );
818819
819 it.effect("reads the OTLP protocol from env", () =>
820 it.effect("gives every signal the protocol named without one", () =>
820821 Effect.gen(function* () {
821822 const { join } = yield* Path.Path;
822823 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-protocol-base");
......@@ -848,7 +849,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
848849 ),
849850 );
850851
851 expect(resolved.otlpProtocol).toBe("http/protobuf");
852 expect([
853 resolved.otlpTracesExport.protocol,
854 resolved.otlpMetricsExport.protocol,
855 resolved.otlpLogsExport.protocol,
856 ]).toEqual(["http/protobuf", "http/protobuf", "http/protobuf"]);
852857 }),
853858 );
854859
apps/server/src/cli/config.ts+16-4
......@@ -1,5 +1,9 @@
11import * as NetService from "@t3tools/shared/Net";
2import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability";
2import {
3 OtlpHeadersFromString,
4 OtlpProtocol,
5 type SignalExport,
6} from "@t3tools/shared/observability";
37import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
48import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";
59import * as Config from "effect/Config";
......@@ -382,6 +386,14 @@ export const resolveServerConfig = (
382386 );
383387 const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel);
384388
389 // T3 Code's own OTLP variables name no signal, so the one answer they give
390 // is the answer for all three.
391 const signalExport: SignalExport = {
392 protocol: env.otlpProtocol,
393 headers: env.otlpHeaders,
394 exportIntervalMs: env.otlpExportIntervalMs,
395 };
396
385397 const config: ServerConfig.ServerConfig["Service"] = {
386398 logLevel,
387399 traceMinLevel: env.traceMinLevel,
......@@ -399,10 +411,10 @@ export const resolveServerConfig = (
399411 persistedObservabilitySettings.otlpMetricsUrl,
400412 otlpLogsUrl:
401413 env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl,
402 otlpExportIntervalMs: env.otlpExportIntervalMs,
414 otlpTracesExport: signalExport,
415 otlpMetricsExport: signalExport,
416 otlpLogsExport: signalExport,
403417 otlpServiceName: env.otlpServiceName,
404 otlpHeaders: env.otlpHeaders,
405 otlpProtocol: env.otlpProtocol,
406418 mode,
407419 port,
408420 cwd,
apps/server/src/cli/pair.ts+4-3
......@@ -15,6 +15,7 @@ import {
1515 PortSchema,
1616} from "@t3tools/contracts";
1717import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
18import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
1819import {
1920 buildTailscaleHttpsBaseUrl,
2021 DEFAULT_TAILSCALE_SERVE_PORT,
......@@ -321,10 +322,10 @@ const makePairServerConfig = Effect.fn(function* (input: {
321322 otlpTracesUrl: undefined,
322323 otlpMetricsUrl: undefined,
323324 otlpLogsUrl: undefined,
324 otlpExportIntervalMs: 10_000,
325 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
326 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
327 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
325328 otlpServiceName: "t3-server",
326 otlpHeaders: undefined,
327 otlpProtocol: "http/json",
328329 mode: "web",
329330 port: state.port,
330331 host: state.host,
apps/server/src/config.ts+12-7
......@@ -17,7 +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";
20import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability";
2121
2222export const DEFAULT_PORT = 3773;
2323
......@@ -73,10 +73,15 @@ export class ServerConfig extends Context.Service<
7373 readonly otlpTracesUrl: string | undefined;
7474 readonly otlpMetricsUrl: string | undefined;
7575 readonly otlpLogsUrl: string | undefined;
76 readonly otlpExportIntervalMs: number;
76 /**
77 * How each signal is exported. Read instead of a process-wide setting so
78 * the wire format, credential, and schedule travel with the endpoint they
79 * were configured beside.
80 */
81 readonly otlpTracesExport: SignalExport;
82 readonly otlpMetricsExport: SignalExport;
83 readonly otlpLogsExport: SignalExport;
7784 readonly otlpServiceName: string;
78 readonly otlpHeaders: Readonly<Record<string, string>> | undefined;
79 readonly otlpProtocol: OtlpProtocol;
8085 readonly mode: RuntimeMode;
8186 readonly port: number;
8287 readonly host: string | undefined;
......@@ -212,10 +217,10 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
212217 otlpTracesUrl: undefined,
213218 otlpMetricsUrl: undefined,
214219 otlpLogsUrl: undefined,
215 otlpExportIntervalMs: 10_000,
220 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
221 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
222 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
216223 otlpServiceName: "t3-server",
217 otlpHeaders: undefined,
218 otlpProtocol: "http/json",
219224 cwd,
220225 baseDir,
221226 ...derivedPaths,
apps/server/src/environment/ServerEnvironment.test.ts+5-3
......@@ -10,6 +10,8 @@ import * as Option from "effect/Option";
1010import * as PlatformError from "effect/PlatformError";
1111import * as Schema from "effect/Schema";
1212
13import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
14
1315import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1416import {
1517 PUBLISH_AGENT_ACTIVITY_SECRET,
......@@ -54,10 +56,10 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
5456 otlpTracesUrl: undefined,
5557 otlpMetricsUrl: undefined,
5658 otlpLogsUrl: undefined,
57 otlpExportIntervalMs: 10_000,
59 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
60 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
61 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
5862 otlpServiceName: "t3-server",
59 otlpHeaders: undefined,
60 otlpProtocol: "http/json",
6163 cwd: process.cwd(),
6264 baseDir,
6365 mode: "web",
apps/server/src/http.ts+1-1
......@@ -322,7 +322,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
322322 const request = yield* HttpServerRequest.HttpServerRequest;
323323 const config = yield* ServerConfig.ServerConfig;
324324 const otlpTracesUrl = config.otlpTracesUrl;
325 const otlpHeaders = config.otlpHeaders;
325 const otlpHeaders = config.otlpTracesExport.headers;
326326 const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;
327327 const httpClient = yield* HttpClient.HttpClient;
328328 const serialization = yield* OtlpSerialization.OtlpSerialization;
apps/server/src/observability/Layers/Observability.ts+10-6
......@@ -20,7 +20,11 @@ import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";
2020export const ObservabilityLive = Layer.unwrap(
2121 Effect.gen(function* () {
2222 const config = yield* ServerConfig.ServerConfig;
23 const serializationLayer = otlpSerializationLayer(config.otlpProtocol);
23 const traces = config.otlpTracesExport;
24 const metrics = config.otlpMetricsExport;
25 // The trace serializer stays in the returned context because the browser
26 // trace forwarder exports on the same signal.
27 const serializationLayer = otlpSerializationLayer(traces.protocol);
2428 const resource = ServerConfig.otlpResource(config);
2529 const attribution = yield* ResourceAttribution.ResourceAttribution;
2630
......@@ -51,8 +55,8 @@ export const ObservabilityLive = Layer.unwrap(
5155 ? undefined
5256 : yield* OtlpTracer.make({
5357 url: config.otlpTracesUrl,
54 exportInterval: `${config.otlpExportIntervalMs} millis`,
55 headers: config.otlpHeaders,
58 exportInterval: `${traces.exportIntervalMs} millis`,
59 headers: traces.headers,
5660 resource,
5761 });
5862
......@@ -77,10 +81,10 @@ export const ObservabilityLive = Layer.unwrap(
7781 ? Layer.empty
7882 : OtlpMetrics.layer({
7983 url: config.otlpMetricsUrl,
80 exportInterval: `${config.otlpExportIntervalMs} millis`,
81 headers: config.otlpHeaders,
84 exportInterval: `${metrics.exportIntervalMs} millis`,
85 headers: metrics.headers,
8286 resource,
83 }).pipe(Layer.provideMerge(serializationLayer));
87 }).pipe(Layer.provide(otlpSerializationLayer(metrics.protocol)));
8488
8589 return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer);
8690 }),
apps/server/src/server.test.ts+6-6
......@@ -218,7 +218,7 @@ import {
218218 transferBudgetViolations,
219219} from "../integration/TransferBudgetReport.integration.ts";
220220import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
221import { otlpSerializationLayer } from "@t3tools/shared/observability";
221import { DEFAULT_SIGNAL_EXPORT, otlpSerializationLayer } from "@t3tools/shared/observability";
222222
223223const defaultProjectId = ProjectId.make("project-default");
224224const defaultThreadId = ThreadId.make("thread-default");
......@@ -578,10 +578,10 @@ const buildAppUnderTest = (options?: {
578578 otlpTracesUrl: undefined,
579579 otlpMetricsUrl: undefined,
580580 otlpLogsUrl: undefined,
581 otlpExportIntervalMs: 10_000,
581 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
582 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
583 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
582584 otlpServiceName: "t3-server",
583 otlpHeaders: undefined,
584 otlpProtocol: "http/json",
585585 mode: "desktop",
586586 port: 0,
587587 host: "127.0.0.1",
......@@ -1076,7 +1076,7 @@ const buildAppUnderTest = (options?: {
10761076 ...options?.layers?.browserTraceCollector,
10771077 }),
10781078 ),
1079 Layer.provide(otlpSerializationLayer(config.otlpProtocol)),
1079 Layer.provide(otlpSerializationLayer(config.otlpTracesExport.protocol)),
10801080 Layer.provide(
10811081 Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({
10821082 publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }),
......@@ -5299,7 +5299,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
52995299 yield* buildAppUnderTest({
53005300 config: {
53015301 otlpTracesUrl: collector.url,
5302 otlpProtocol: "http/protobuf",
5302 otlpTracesExport: { ...DEFAULT_SIGNAL_EXPORT, protocol: "http/protobuf" },
53035303 },
53045304 layers: {
53055305 browserTraceCollector: {
apps/server/src/serverLogger.test.ts+11-6
......@@ -8,6 +8,8 @@ import * as Tracer from "effect/Tracer";
88import * as HttpClient from "effect/unstable/http/HttpClient";
99import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
1010
11import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
12
1113import * as ServerConfig from "./config.ts";
1214import { ServerLoggerLive } from "./serverLogger.ts";
1315
......@@ -51,10 +53,10 @@ const configLayer = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) =
5153 otlpTracesUrl: undefined,
5254 otlpMetricsUrl: undefined,
5355 otlpLogsUrl: undefined,
54 otlpExportIntervalMs: 10_000,
56 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
57 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
58 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
5559 otlpServiceName: "t3-server",
56 otlpHeaders: undefined,
57 otlpProtocol: "http/json",
5860 cwd: baseDir,
5961 baseDir,
6062 ...derivedPaths,
......@@ -155,12 +157,15 @@ describe("ServerLoggerLive", () => {
155157 }),
156158 );
157159
158 it.effect("sends the headers and wire format the rest of OTLP export already uses", () =>
160 it.effect("sends the headers and wire format the log signal asked for", () =>
159161 Effect.gen(function* () {
160162 const requests = yield* logThrough({
161163 otlpLogsUrl: "https://collector.example.com/v1/logs",
162 otlpProtocol: "http/protobuf",
163 otlpHeaders: { "x-scope": "logs" },
164 otlpLogsExport: {
165 ...DEFAULT_SIGNAL_EXPORT,
166 protocol: "http/protobuf",
167 headers: { "x-scope": "logs" },
168 },
164169 });
165170
166171 assert.lengthOf(requests, 1);
apps/server/src/serverLogger.ts+4-3
......@@ -12,13 +12,14 @@ export const ServerLoggerLive = Effect.gen(function* () {
1212 const config = yield* ServerConfig;
1313 const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel);
1414
15 const logs = config.otlpLogsExport;
1516 const otlpLogger =
1617 config.otlpLogsUrl === undefined
1718 ? undefined
1819 : OtlpLogger.make({
1920 url: config.otlpLogsUrl,
20 exportInterval: `${config.otlpExportIntervalMs} millis`,
21 headers: config.otlpHeaders,
21 exportInterval: `${logs.exportIntervalMs} millis`,
22 headers: logs.headers,
2223 resource: otlpResource(config),
2324 });
2425
......@@ -41,7 +42,7 @@ export const ServerLoggerLive = Effect.gen(function* () {
4142 { mergeWithExisting: false },
4243 ).pipe(
4344 Layer.provide(OtlpExporter.layerFlusher),
44 Layer.provide(otlpSerializationLayer(config.otlpProtocol)),
45 Layer.provide(otlpSerializationLayer(logs.protocol)),
4546 );
4647
4748 return Layer.mergeAll(loggerLayer, minimumLogLevelLayer);
packages/shared/src/observability.ts+19
......@@ -16,6 +16,25 @@ export type OtlpProtocol = typeof OtlpProtocol.Type;
1616export const otlpSerializationLayer = (protocol: OtlpProtocol) =>
1717 protocol === "http/protobuf" ? OtlpSerialization.layerProtobuf : OtlpSerialization.layerJson;
1818
19/**
20 * How one signal is exported, once whichever source named that signal's
21 * endpoint has been resolved. Held per signal rather than per process, so a
22 * wire format or a credential cannot be paired by hand with an endpoint that
23 * came from somewhere else.
24 */
25export interface SignalExport {
26 readonly protocol: OtlpProtocol;
27 readonly headers: Readonly<Record<string, string>> | undefined;
28 readonly exportIntervalMs: number;
29}
30
31/** What T3 Code exports with when nothing configured a signal. */
32export const DEFAULT_SIGNAL_EXPORT: SignalExport = {
33 protocol: "http/json",
34 headers: undefined,
35 exportIntervalMs: 10_000,
36};
37
1938const FLUSH_BUFFER_THRESHOLD = 256;
2039const textEncoder = new TextEncoder();
2140