Skip to content

Commit 367518f

Browse files
authored
feat(server): honor the standard OpenTelemetry environment variables (#31)
2 parents 1f9ff0f + d8312bc commit 367518f

14 files changed

Lines changed: 1514 additions & 31 deletions

‎apps/server/src/bin.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
4141
import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
4242
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
4343
import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
44+
import * as OtelEnvironment from "./observability/OtelEnvironment.ts";
4445

4546
const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
4647
class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}
@@ -75,7 +76,9 @@ const makeCliTestServerConfig = (baseDir: string) =>
7576
otlpTracesUrl: undefined,
7677
otlpMetricsUrl: undefined,
7778
otlpExportIntervalMs: 10_000,
79+
otlpMetricsExportIntervalMs: 10_000,
7880
otlpServiceName: "t3-server",
81+
otelEnvironment: OtelEnvironment.none,
7982
mode: "web",
8083
port: 0,
8184
host: "127.0.0.1",

‎apps/server/src/cli/config.test.ts‎

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @effect-diagnostics nodeBuiltinImport:off
22
import * as NodeFS from "node:fs";
33
import * as NodeOS from "node:os";
4+
import * as NodePath from "node:path";
45

56
import { assert, expect, it } from "@effect/vitest";
67
import * as ConfigProvider from "effect/ConfigProvider";
@@ -18,6 +19,7 @@ import {
1819
import * as NetService from "@t3tools/shared/Net";
1920
import * as NodeServices from "@effect/platform-node/NodeServices";
2021
import { deriveServerPaths } from "../config.ts";
22+
import * as OtelEnvironment from "../observability/OtelEnvironment.ts";
2123
import { resolveServerConfig } from "./config.ts";
2224

2325
const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) =>
@@ -49,7 +51,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
4951
otlpTracesUrl: undefined,
5052
otlpMetricsUrl: undefined,
5153
otlpExportIntervalMs: 10_000,
54+
otlpMetricsExportIntervalMs: 10_000,
5255
otlpServiceName: "t3-server",
56+
otelEnvironment: OtelEnvironment.none,
5357
devAllowedOrigins: [],
5458
} as const;
5559

@@ -488,6 +492,161 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
488492
}),
489493
);
490494

495+
// Resolving a config reads the settings file and creates the trace
496+
// directory, so a shared home would let one case see another's writes and
497+
// would race when these run in parallel.
498+
const resolveWithEnv = (env: Record<string, string>) => {
499+
const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-otel-config-"));
500+
return resolveServerConfig(
501+
{
502+
mode: Option.some("web"),
503+
port: Option.some(4888),
504+
host: Option.none(),
505+
baseDir: Option.some(baseDir),
506+
cwd: Option.none(),
507+
devUrl: Option.none(),
508+
noBrowser: Option.none(),
509+
bootstrapFd: Option.none(),
510+
autoBootstrapProjectFromCwd: Option.none(),
511+
logWebSocketEvents: Option.none(),
512+
tailscaleServeEnabled: Option.none(),
513+
tailscaleServePort: Option.none(),
514+
},
515+
Option.none(),
516+
).pipe(
517+
Effect.provide(
518+
Layer.mergeAll(ConfigProvider.layer(ConfigProvider.fromEnv({ env })), NetService.layer),
519+
),
520+
Effect.ensuring(
521+
Effect.sync(() => {
522+
NodeFS.rmSync(baseDir, { recursive: true, force: true });
523+
}),
524+
),
525+
);
526+
};
527+
528+
it.effect("exports to the endpoint the rest of the machine already uses", () =>
529+
Effect.gen(function* () {
530+
const resolved = yield* resolveWithEnv({
531+
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
532+
OTEL_SERVICE_NAME: "t3",
533+
});
534+
535+
expect(resolved.otlpTracesUrl).toBe("https://collector.example.com/v1/traces");
536+
expect(resolved.otlpMetricsUrl).toBe("https://collector.example.com/v1/metrics");
537+
expect(resolved.otlpServiceName).toBe("t3");
538+
}),
539+
);
540+
541+
it.effect("does not let an empty T3 Code name stand in for an answer", () =>
542+
Effect.gen(function* () {
543+
// An empty variable is set without saying anything. Reading it as an
544+
// answer would publish an endpoint nothing can reach and would suppress
545+
// the ambient one that could have been used instead.
546+
const resolved = yield* resolveWithEnv({
547+
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
548+
OTEL_SERVICE_NAME: "t3",
549+
T3CODE_OTLP_TRACES_URL: "",
550+
T3CODE_OTLP_METRICS_URL: " ",
551+
T3CODE_OTLP_SERVICE_NAME: "",
552+
});
553+
554+
expect(resolved.otlpTracesUrl).toBe("https://collector.example.com/v1/traces");
555+
expect(resolved.otlpMetricsUrl).toBe("https://collector.example.com/v1/metrics");
556+
expect(resolved.otlpServiceName).toBe("t3");
557+
}),
558+
);
559+
560+
it.effect("keeps T3 Code's own names as the explicit answer", () =>
561+
Effect.gen(function* () {
562+
const resolved = yield* resolveWithEnv({
563+
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
564+
OTEL_SERVICE_NAME: "t3",
565+
T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces",
566+
T3CODE_OTLP_SERVICE_NAME: "t3-local",
567+
});
568+
569+
expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
570+
expect(resolved.otlpMetricsUrl).toBe("https://collector.example.com/v1/metrics");
571+
expect(resolved.otlpServiceName).toBe("t3-local");
572+
}),
573+
);
574+
575+
it.effect("leaves a T3 Code endpoint alone when the environment names another", () =>
576+
Effect.gen(function* () {
577+
// An ambient endpoint that lost the URL must not keep configuring the
578+
// export around it: its wire format, headers, and batching belong to the
579+
// endpoint it named, not to this one.
580+
const resolved = yield* resolveWithEnv({
581+
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
582+
T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces",
583+
});
584+
585+
expect(resolved.otelEnvironment.traces.settings).toBeUndefined();
586+
expect(resolved.otelEnvironment.metrics.settings?.url).toBe(
587+
"https://collector.example.com/v1/metrics",
588+
);
589+
expect(resolved.otlpExportIntervalMs).toBe(10_000);
590+
}),
591+
);
592+
593+
it.effect("keeps an ambient aggregation off a T3 Code metric endpoint", () =>
594+
Effect.gen(function* () {
595+
const resolved = yield* resolveWithEnv({
596+
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
597+
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "delta",
598+
T3CODE_OTLP_METRICS_URL: "http://localhost:4318/v1/metrics",
599+
});
600+
601+
expect(resolved.otelEnvironment.metrics.settings).toBeUndefined();
602+
expect(resolved.otelEnvironment.traces.settings?.temporality).toBeUndefined();
603+
}),
604+
);
605+
606+
it.effect("keeps one signal's schedule off the other one", () =>
607+
Effect.gen(function* () {
608+
// Traces take the specification's five second batch delay from the
609+
// ambient endpoint. Metrics went somewhere else and keep T3 Code's own
610+
// interval rather than inheriting a number meant for spans.
611+
const resolved = yield* resolveWithEnv({
612+
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
613+
T3CODE_OTLP_METRICS_URL: "http://localhost:4318/v1/metrics",
614+
});
615+
616+
expect(resolved.otlpExportIntervalMs).toBe(5_000);
617+
expect(resolved.otlpMetricsExportIntervalMs).toBe(10_000);
618+
}),
619+
);
620+
621+
it.effect("does not report a signal as declined while it is exporting", () =>
622+
Effect.gen(function* () {
623+
// grpc turns off the export these variables asked for, and says nothing
624+
// about a signal whose endpoint came from a T3 Code name.
625+
const resolved = yield* resolveWithEnv({
626+
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
627+
OTEL_EXPORTER_OTLP_PROTOCOL: "grpc",
628+
T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces",
629+
});
630+
631+
expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
632+
expect(resolved.otelEnvironment.traces.declined).toBeUndefined();
633+
expect(resolved.otelEnvironment.metrics.declined).toContain("grpc");
634+
}),
635+
);
636+
637+
it.effect("exports nothing at all once the SDK is switched off", () =>
638+
Effect.gen(function* () {
639+
const resolved = yield* resolveWithEnv({
640+
OTEL_SDK_DISABLED: "true",
641+
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
642+
T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces",
643+
});
644+
645+
expect(resolved.otlpTracesUrl).toBeUndefined();
646+
expect(resolved.otlpMetricsUrl).toBeUndefined();
647+
}),
648+
);
649+
491650
it.effect("falls back to persisted observability settings when env vars are absent", () =>
492651
Effect.gen(function* () {
493652
const fs = yield* FileSystem.FileSystem;

‎apps/server/src/cli/config.ts‎

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Argument, Flag } from "effect/unstable/cli";
1616
import { readBootstrapEnvelope } from "../bootstrap.ts";
1717
import * as ServerConfig from "../config.ts";
1818
import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
19+
import * as OtelEnvironment from "../observability/OtelEnvironment.ts";
1920

2021
export const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe(
2122
Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
@@ -95,9 +96,13 @@ const EnvServerConfig = Config.all({
9596
Config.map(Option.getOrUndefined),
9697
),
9798
otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
98-
Config.withDefault(10_000),
99+
Config.option,
100+
Config.map(Option.getOrUndefined),
101+
),
102+
otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(
103+
Config.option,
104+
Config.map(Option.getOrUndefined),
99105
),
100-
otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")),
101106
mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe(
102107
Config.option,
103108
Config.map(Option.getOrUndefined),
@@ -196,6 +201,17 @@ const resolveOptionPrecedence = <Value>(
196201
...values: ReadonlyArray<Option.Option<Value>>
197202
): Option.Option<Value> => Option.firstSomeOf(values);
198203

204+
/**
205+
* Reads a source that names an OTLP destination, treating a blank one as
206+
* nobody having named it. An empty variable is set in the environment but is
207+
* not an answer, and taking it as one both publishes an endpoint that cannot
208+
* be reached and suppresses the ambient variable that could have been.
209+
*/
210+
const named = (value: string | undefined) => {
211+
const trimmed = value?.trim();
212+
return trimmed === undefined || trimmed === "" ? undefined : trimmed;
213+
};
214+
199215
const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) {
200216
const fs = yield* FileSystem.FileSystem;
201217
const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false));
@@ -220,6 +236,7 @@ export const resolveServerConfig = (
220236
const path = yield* Path.Path;
221237
const fs = yield* FileSystem.FileSystem;
222238
const env = yield* EnvServerConfig;
239+
const otel = yield* OtelEnvironment.load;
223240
const normalizedFlags = {
224241
mode: flags.mode ?? Option.none(),
225242
port: flags.port ?? Option.none(),
@@ -349,23 +366,50 @@ export const resolveServerConfig = (
349366
);
350367
const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel);
351368

369+
// A signal whose endpoint came from somewhere else is not this route's to
370+
// configure. Dropping the whole signal, rather than the endpoint alone,
371+
// is what stops an ambient OTEL_EXPORTER_OTLP_ENDPOINT from changing the
372+
// wire format, headers, batching, or aggregation of an export that a
373+
// T3CODE_OTLP_* name or Settings already answered, and stops startup from
374+
// reporting that signal as declined while it is exporting.
375+
const namedTracesUrl = named(
376+
env.otlpTracesUrl ?? bootstrap?.otlpTracesUrl ?? persistedObservabilitySettings.otlpTracesUrl,
377+
);
378+
const namedMetricsUrl = named(
379+
env.otlpMetricsUrl ??
380+
bootstrap?.otlpMetricsUrl ??
381+
persistedObservabilitySettings.otlpMetricsUrl,
382+
);
383+
const otelEnvironment = {
384+
...otel,
385+
traces: namedTracesUrl === undefined ? otel.traces : OtelEnvironment.noSignal,
386+
metrics: namedMetricsUrl === undefined ? otel.metrics : OtelEnvironment.noSignal,
387+
} satisfies OtelEnvironment.OtelEnvironment;
388+
352389
const config: ServerConfig.ServerConfig["Service"] = {
353390
logLevel,
354391
traceMinLevel: env.traceMinLevel,
355392
traceTimingEnabled: env.traceTimingEnabled,
356393
traceBatchWindowMs: env.traceBatchWindowMs,
357394
traceMaxBytes: env.traceMaxBytes,
358395
traceMaxFiles: env.traceMaxFiles,
359-
otlpTracesUrl:
360-
env.otlpTracesUrl ??
361-
bootstrap?.otlpTracesUrl ??
362-
persistedObservabilitySettings.otlpTracesUrl,
363-
otlpMetricsUrl:
364-
env.otlpMetricsUrl ??
365-
bootstrap?.otlpMetricsUrl ??
366-
persistedObservabilitySettings.otlpMetricsUrl,
367-
otlpExportIntervalMs: env.otlpExportIntervalMs,
368-
otlpServiceName: env.otlpServiceName,
396+
otlpTracesUrl: otelEnvironment.disabled
397+
? undefined
398+
: (namedTracesUrl ?? otelEnvironment.traces.settings?.url),
399+
otlpMetricsUrl: otelEnvironment.disabled
400+
? undefined
401+
: (namedMetricsUrl ?? otelEnvironment.metrics.settings?.url),
402+
// T3 Code has one interval variable and it deliberately covers both
403+
// signals. The per-signal part is the fallback under it: the environment
404+
// names a trace delay and a metric interval separately, so a signal that
405+
// took its endpoint elsewhere must not inherit the other one's.
406+
otlpExportIntervalMs:
407+
env.otlpExportIntervalMs ?? otelEnvironment.traces.settings?.exportIntervalMs ?? 10_000,
408+
otlpMetricsExportIntervalMs:
409+
env.otlpExportIntervalMs ?? otelEnvironment.metrics.settings?.exportIntervalMs ?? 10_000,
410+
otlpServiceName:
411+
named(env.otlpServiceName) ?? otelEnvironment.resource.serviceName ?? "t3-server",
412+
otelEnvironment,
369413
mode,
370414
port,
371415
cwd,

‎apps/server/src/cli/pair.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
resolveHeadlessConnectionString,
5555
} from "../startupAccess.ts";
5656
import { baseDirFlag, DurationFromString } from "./config.ts";
57+
import * as OtelEnvironment from "../observability/OtelEnvironment.ts";
5758

5859
const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment";
5960
const PAIR_PROBE_TIMEOUT = Duration.millis(2_500);
@@ -331,7 +332,9 @@ const makePairServerConfig = Effect.fn(function* (input: {
331332
otlpTracesUrl: undefined,
332333
otlpMetricsUrl: undefined,
333334
otlpExportIntervalMs: 10_000,
335+
otlpMetricsExportIntervalMs: 10_000,
334336
otlpServiceName: "t3-server",
337+
otelEnvironment: OtelEnvironment.none,
335338
mode: "web",
336339
port: state.port,
337340
host: state.host,

‎apps/server/src/config.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import * as LogLevel from "effect/LogLevel";
1414
import * as Path from "effect/Path";
1515
import * as Schema from "effect/Schema";
1616

17+
import * as OtelEnvironment from "./observability/OtelEnvironment.ts";
18+
1719
export const DEFAULT_PORT = 3773;
1820

1921
export const RuntimeMode = Schema.Literals(["web", "desktop"]);
@@ -64,7 +66,15 @@ export class ServerConfig extends Context.Service<
6466
readonly otlpTracesUrl: string | undefined;
6567
readonly otlpMetricsUrl: string | undefined;
6668
readonly otlpExportIntervalMs: number;
69+
readonly otlpMetricsExportIntervalMs: number;
6770
readonly otlpServiceName: string;
71+
/**
72+
* What the standard `OTEL_*` variables asked for. The endpoints above are
73+
* already resolved from it; this carries the rest, which T3 Code has no
74+
* names of its own for: headers, wire format, resource attributes, and the
75+
* batching knobs.
76+
*/
77+
readonly otelEnvironment: OtelEnvironment.OtelEnvironment;
6878
readonly mode: RuntimeMode;
6979
readonly port: number;
7080
readonly host: string | undefined;
@@ -177,7 +187,9 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
177187
otlpTracesUrl: undefined,
178188
otlpMetricsUrl: undefined,
179189
otlpExportIntervalMs: 10_000,
190+
otlpMetricsExportIntervalMs: 10_000,
180191
otlpServiceName: "t3-server",
192+
otelEnvironment: OtelEnvironment.none,
181193
cwd,
182194
baseDir,
183195
...derivedPaths,

‎apps/server/src/environment/ServerEnvironment.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "../cloud/config.ts";
1616
import * as ServerConfig from "../config.ts";
1717
import * as ServerEnvironment from "./ServerEnvironment.ts";
18+
import * as OtelEnvironment from "../observability/OtelEnvironment.ts";
1819

1920
const isServerEnvironmentIdPersistenceError = Schema.is(
2021
ServerEnvironment.ServerEnvironmentIdPersistenceError,
@@ -51,7 +52,9 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
5152
otlpTracesUrl: undefined,
5253
otlpMetricsUrl: undefined,
5354
otlpExportIntervalMs: 10_000,
55+
otlpMetricsExportIntervalMs: 10_000,
5456
otlpServiceName: "t3-server",
57+
otelEnvironment: OtelEnvironment.none,
5558
cwd: process.cwd(),
5659
baseDir,
5760
mode: "web",

0 commit comments

Comments
 (0)