Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/server/src/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
import * as OtelEnvironment from "./observability/OtelEnvironment.ts";

const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}
Expand Down Expand Up @@ -75,7 +76,9 @@ const makeCliTestServerConfig = (baseDir: string) =>
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpMetricsExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
otelEnvironment: OtelEnvironment.none,
mode: "web",
port: 0,
host: "127.0.0.1",
Expand Down
159 changes: 159 additions & 0 deletions apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import { assert, expect, it } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
Expand All @@ -18,6 +19,7 @@ import {
import * as NetService from "@t3tools/shared/Net";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { deriveServerPaths } from "../config.ts";
import * as OtelEnvironment from "../observability/OtelEnvironment.ts";
import { resolveServerConfig } from "./config.ts";

const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) =>
Expand Down Expand Up @@ -49,7 +51,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpMetricsExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
otelEnvironment: OtelEnvironment.none,
devAllowedOrigins: [],
} as const;

Expand Down Expand Up @@ -488,6 +492,161 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
}),
);

// Resolving a config reads the settings file and creates the trace
// directory, so a shared home would let one case see another's writes and
// would race when these run in parallel.
const resolveWithEnv = (env: Record<string, string>) => {
const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-otel-config-"));
return resolveServerConfig(
{
mode: Option.some("web"),
port: Option.some(4888),
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 })), NetService.layer),
),
Effect.ensuring(
Effect.sync(() => {
NodeFS.rmSync(baseDir, { recursive: true, force: true });
}),
),
);
};

it.effect("exports to the endpoint the rest of the machine already uses", () =>
Effect.gen(function* () {
const resolved = yield* resolveWithEnv({
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
OTEL_SERVICE_NAME: "t3",
});

expect(resolved.otlpTracesUrl).toBe("https://collector.example.com/v1/traces");
expect(resolved.otlpMetricsUrl).toBe("https://collector.example.com/v1/metrics");
expect(resolved.otlpServiceName).toBe("t3");
}),
);

it.effect("does not let an empty T3 Code name stand in for an answer", () =>
Effect.gen(function* () {
// An empty variable is set without saying anything. Reading it as an
// answer would publish an endpoint nothing can reach and would suppress
// the ambient one that could have been used instead.
const resolved = yield* resolveWithEnv({
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
OTEL_SERVICE_NAME: "t3",
T3CODE_OTLP_TRACES_URL: "",
T3CODE_OTLP_METRICS_URL: " ",
T3CODE_OTLP_SERVICE_NAME: "",
});

expect(resolved.otlpTracesUrl).toBe("https://collector.example.com/v1/traces");
expect(resolved.otlpMetricsUrl).toBe("https://collector.example.com/v1/metrics");
expect(resolved.otlpServiceName).toBe("t3");
}),
);

it.effect("keeps T3 Code's own names as the explicit answer", () =>
Effect.gen(function* () {
const resolved = yield* resolveWithEnv({
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
OTEL_SERVICE_NAME: "t3",
T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces",
T3CODE_OTLP_SERVICE_NAME: "t3-local",
});

expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
expect(resolved.otlpMetricsUrl).toBe("https://collector.example.com/v1/metrics");
expect(resolved.otlpServiceName).toBe("t3-local");
}),
);

it.effect("leaves a T3 Code endpoint alone when the environment names another", () =>
Effect.gen(function* () {
// An ambient endpoint that lost the URL must not keep configuring the
// export around it: its wire format, headers, and batching belong to the
// endpoint it named, not to this one.
const resolved = yield* resolveWithEnv({
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces",
});

expect(resolved.otelEnvironment.traces.settings).toBeUndefined();
expect(resolved.otelEnvironment.metrics.settings?.url).toBe(
"https://collector.example.com/v1/metrics",
);
expect(resolved.otlpExportIntervalMs).toBe(10_000);
}),
);

it.effect("keeps an ambient aggregation off a T3 Code metric endpoint", () =>
Effect.gen(function* () {
const resolved = yield* resolveWithEnv({
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "delta",
T3CODE_OTLP_METRICS_URL: "http://localhost:4318/v1/metrics",
});

expect(resolved.otelEnvironment.metrics.settings).toBeUndefined();
expect(resolved.otelEnvironment.traces.settings?.temporality).toBeUndefined();
}),
);

it.effect("keeps one signal's schedule off the other one", () =>
Effect.gen(function* () {
// Traces take the specification's five second batch delay from the
// ambient endpoint. Metrics went somewhere else and keep T3 Code's own
// interval rather than inheriting a number meant for spans.
const resolved = yield* resolveWithEnv({
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
T3CODE_OTLP_METRICS_URL: "http://localhost:4318/v1/metrics",
});

expect(resolved.otlpExportIntervalMs).toBe(5_000);
expect(resolved.otlpMetricsExportIntervalMs).toBe(10_000);
}),
);

it.effect("does not report a signal as declined while it is exporting", () =>
Effect.gen(function* () {
// grpc turns off the export these variables asked for, and says nothing
// about a signal whose endpoint came from a T3 Code name.
const resolved = yield* resolveWithEnv({
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
OTEL_EXPORTER_OTLP_PROTOCOL: "grpc",
T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces",
});

expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
expect(resolved.otelEnvironment.traces.declined).toBeUndefined();
expect(resolved.otelEnvironment.metrics.declined).toContain("grpc");
}),
);

it.effect("exports nothing at all once the SDK is switched off", () =>
Effect.gen(function* () {
const resolved = yield* resolveWithEnv({
OTEL_SDK_DISABLED: "true",
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
T3CODE_OTLP_TRACES_URL: "http://localhost:4318/v1/traces",
});

expect(resolved.otlpTracesUrl).toBeUndefined();
expect(resolved.otlpMetricsUrl).toBeUndefined();
}),
);

it.effect("falls back to persisted observability settings when env vars are absent", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
68 changes: 56 additions & 12 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Argument, Flag } from "effect/unstable/cli";
import { readBootstrapEnvelope } from "../bootstrap.ts";
import * as ServerConfig from "../config.ts";
import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
import * as OtelEnvironment from "../observability/OtelEnvironment.ts";

export const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe(
Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
Expand Down Expand Up @@ -95,9 +96,13 @@ const EnvServerConfig = Config.all({
Config.map(Option.getOrUndefined),
),
otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
Config.withDefault(10_000),
Config.option,
Config.map(Option.getOrUndefined),
),
otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(
Config.option,
Config.map(Option.getOrUndefined),
),
otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")),
mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe(
Config.option,
Config.map(Option.getOrUndefined),
Expand Down Expand Up @@ -196,6 +201,17 @@ const resolveOptionPrecedence = <Value>(
...values: ReadonlyArray<Option.Option<Value>>
): Option.Option<Value> => Option.firstSomeOf(values);

/**
* Reads a source that names an OTLP destination, treating a blank one as
* nobody having named it. An empty variable is set in the environment but is
* not an answer, and taking it as one both publishes an endpoint that cannot
* be reached and suppresses the ambient variable that could have been.
*/
const named = (value: string | undefined) => {
const trimmed = value?.trim();
return trimmed === undefined || trimmed === "" ? undefined : trimmed;
};

const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) {
const fs = yield* FileSystem.FileSystem;
const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false));
Expand All @@ -220,6 +236,7 @@ export const resolveServerConfig = (
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const env = yield* EnvServerConfig;
const otel = yield* OtelEnvironment.load;
const normalizedFlags = {
mode: flags.mode ?? Option.none(),
port: flags.port ?? Option.none(),
Expand Down Expand Up @@ -349,23 +366,50 @@ export const resolveServerConfig = (
);
const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel);

// A signal whose endpoint came from somewhere else is not this route's to
// configure. Dropping the whole signal, rather than the endpoint alone,
// is what stops an ambient OTEL_EXPORTER_OTLP_ENDPOINT from changing the
// wire format, headers, batching, or aggregation of an export that a
// T3CODE_OTLP_* name or Settings already answered, and stops startup from
// reporting that signal as declined while it is exporting.
const namedTracesUrl = named(
env.otlpTracesUrl ?? bootstrap?.otlpTracesUrl ?? persistedObservabilitySettings.otlpTracesUrl,
);
const namedMetricsUrl = named(
env.otlpMetricsUrl ??
bootstrap?.otlpMetricsUrl ??
persistedObservabilitySettings.otlpMetricsUrl,
);
const otelEnvironment = {
...otel,
traces: namedTracesUrl === undefined ? otel.traces : OtelEnvironment.noSignal,
metrics: namedMetricsUrl === undefined ? otel.metrics : OtelEnvironment.noSignal,
} satisfies OtelEnvironment.OtelEnvironment;
Comment thread
yordis marked this conversation as resolved.
Comment thread
yordis marked this conversation as resolved.
Comment thread
yordis marked this conversation as resolved.

const config: ServerConfig.ServerConfig["Service"] = {
logLevel,
traceMinLevel: env.traceMinLevel,
traceTimingEnabled: env.traceTimingEnabled,
traceBatchWindowMs: env.traceBatchWindowMs,
traceMaxBytes: env.traceMaxBytes,
traceMaxFiles: env.traceMaxFiles,
otlpTracesUrl:
env.otlpTracesUrl ??
bootstrap?.otlpTracesUrl ??
persistedObservabilitySettings.otlpTracesUrl,
otlpMetricsUrl:
env.otlpMetricsUrl ??
bootstrap?.otlpMetricsUrl ??
persistedObservabilitySettings.otlpMetricsUrl,
otlpExportIntervalMs: env.otlpExportIntervalMs,
otlpServiceName: env.otlpServiceName,
otlpTracesUrl: otelEnvironment.disabled
? undefined
: (namedTracesUrl ?? otelEnvironment.traces.settings?.url),
otlpMetricsUrl: otelEnvironment.disabled
? undefined
: (namedMetricsUrl ?? otelEnvironment.metrics.settings?.url),
Comment thread
yordis marked this conversation as resolved.
// T3 Code has one interval variable and it deliberately covers both
// signals. The per-signal part is the fallback under it: the environment
// names a trace delay and a metric interval separately, so a signal that
// took its endpoint elsewhere must not inherit the other one's.
otlpExportIntervalMs:
env.otlpExportIntervalMs ?? otelEnvironment.traces.settings?.exportIntervalMs ?? 10_000,
otlpMetricsExportIntervalMs:
env.otlpExportIntervalMs ?? otelEnvironment.metrics.settings?.exportIntervalMs ?? 10_000,
otlpServiceName:
named(env.otlpServiceName) ?? otelEnvironment.resource.serviceName ?? "t3-server",
otelEnvironment,
mode,
port,
cwd,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/cli/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
resolveHeadlessConnectionString,
} from "../startupAccess.ts";
import { baseDirFlag, DurationFromString } from "./config.ts";
import * as OtelEnvironment from "../observability/OtelEnvironment.ts";

const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment";
const PAIR_PROBE_TIMEOUT = Duration.millis(2_500);
Expand Down Expand Up @@ -331,7 +332,9 @@ const makePairServerConfig = Effect.fn(function* (input: {
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpMetricsExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
otelEnvironment: OtelEnvironment.none,
mode: "web",
port: state.port,
host: state.host,
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import * as LogLevel from "effect/LogLevel";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

import * as OtelEnvironment from "./observability/OtelEnvironment.ts";

export const DEFAULT_PORT = 3773;

export const RuntimeMode = Schema.Literals(["web", "desktop"]);
Expand Down Expand Up @@ -64,7 +66,15 @@ export class ServerConfig extends Context.Service<
readonly otlpTracesUrl: string | undefined;
readonly otlpMetricsUrl: string | undefined;
readonly otlpExportIntervalMs: number;
readonly otlpMetricsExportIntervalMs: number;
readonly otlpServiceName: string;
/**
* What the standard `OTEL_*` variables asked for. The endpoints above are
* already resolved from it; this carries the rest, which T3 Code has no
* names of its own for: headers, wire format, resource attributes, and the
* batching knobs.
*/
readonly otelEnvironment: OtelEnvironment.OtelEnvironment;
readonly mode: RuntimeMode;
readonly port: number;
readonly host: string | undefined;
Expand Down Expand Up @@ -177,7 +187,9 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpMetricsExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
otelEnvironment: OtelEnvironment.none,
cwd,
baseDir,
...derivedPaths,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "../cloud/config.ts";
import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "./ServerEnvironment.ts";
import * as OtelEnvironment from "../observability/OtelEnvironment.ts";

const isServerEnvironmentIdPersistenceError = Schema.is(
ServerEnvironment.ServerEnvironmentIdPersistenceError,
Expand Down Expand Up @@ -51,7 +52,9 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpMetricsExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
otelEnvironment: OtelEnvironment.none,
cwd: process.cwd(),
baseDir,
mode: "web",
Expand Down
Loading
Loading