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
105 changes: 97 additions & 8 deletions apps/desktop/src/app/DesktopObservability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ const collectorLayer = (requests: Array<ExportedRequest>) =>
),
);

// A developer's own OTEL_* variables would otherwise pick the endpoints.
const emptyEnv = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }));

const encodeObservabilitySettingsFile = Schema.encodeSync(
Schema.fromJsonString(
Schema.Struct({ observability: Schema.Record(Schema.String, Schema.String) }),
Expand Down Expand Up @@ -182,7 +185,7 @@ describe("DesktopObservability", () => {
assert.isFalse(yield* fileSystem.exists(logPath));
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)),
),
);

Expand Down Expand Up @@ -260,7 +263,7 @@ describe("DesktopObservability", () => {
);
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)),
),
);

Expand Down Expand Up @@ -300,7 +303,7 @@ describe("DesktopObservability", () => {
assert.equal(records.at(-1)?.annotations.details, "code=1");
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)),
),
);

Expand Down Expand Up @@ -344,7 +347,7 @@ describe("DesktopObservability", () => {
assert.isFalse(text.includes("y"));
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)),
),
);

Expand Down Expand Up @@ -378,7 +381,7 @@ describe("DesktopObservability", () => {
assert.equal(lines.length, 258);
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)),
),
);

Expand Down Expand Up @@ -424,7 +427,93 @@ describe("DesktopObservability", () => {
assert.lengthOf(record?.events ?? [], 0);
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)),
);
});

it.effect("exports to an OTEL endpoint over Settings, with its own headers and protocol", () => {
const requests: Array<ExportedRequest> = [];
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-observability-test-",
});
const environmentLayer = makeEnvironmentLayer(baseDir, true, {
T3CODE_OTLP_HEADERS: "x-scope=desktop",
});
yield* writeObservabilitySettings(environmentLayer, {
otlpLogsUrl: "https://settings.example.com/v1/logs",
});

yield* Effect.scoped(
Effect.logInfo("desktop otel export").pipe(
Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
),
);

assert.lengthOf(requests, 1);
const [request] = requests;
assert.strictEqual(request?.url, "https://collector.example.com/v1/logs");
assert.strictEqual(request?.headers["x-otel"], "desktop");
assert.strictEqual(request?.headers["x-scope"], undefined);
assert.strictEqual(request?.headers["content-type"], "application/json");
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(
NodeServices.layer,
collectorLayer(requests),
ConfigProvider.layer(
ConfigProvider.fromEnv({
env: {
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
OTEL_EXPORTER_OTLP_HEADERS: "x-otel=desktop",
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json",
},
}),
),
),
),
);
});

it.effect("exports nothing to Settings for logs an unusable OTEL endpoint claimed", () => {
const requests: Array<ExportedRequest> = [];
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-observability-test-",
});
const environmentLayer = makeEnvironmentLayer(baseDir, true, {
T3CODE_OTLP_HEADERS: "x-scope=desktop",
});
yield* writeObservabilitySettings(environmentLayer, {
otlpLogsUrl: "https://settings.example.com/v1/logs",
});

yield* Effect.scoped(
Effect.logInfo("desktop otel off").pipe(
Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
),
);

assert.lengthOf(requests, 0);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(
NodeServices.layer,
collectorLayer(requests),
ConfigProvider.layer(
ConfigProvider.fromEnv({
env: {
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com",
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc",
},
}),
),
),
),
);
});

Expand Down Expand Up @@ -495,7 +584,7 @@ describe("DesktopObservability", () => {
);
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)),
);
});

Expand All @@ -519,7 +608,7 @@ describe("DesktopObservability", () => {
assert.lengthOf(requests, 0);
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)),
);
});

Expand Down
64 changes: 45 additions & 19 deletions apps/desktop/src/app/DesktopObservability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
makeLocalFileTracer,
makeTraceSink,
otlpSerializationLayer,
type SignalExport,
} from "@t3tools/shared/observability";
import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
import {
Expand Down Expand Up @@ -350,8 +351,10 @@ const readPersistedObservabilitySettings: Effect.Effect<
});

/**
* Settings is read once for every signal, so the main process cannot
* resolve traces against one revision of the file and logs against another.
* Resolved as the server resolves them, with persisted Settings as the
* fallback. Settings is read once for every signal, so the main process
* cannot resolve traces against one revision of the file and logs against
* another.
*/
const resolveOtlpEndpoints = Effect.gen(function* () {
const otel = yield* OtelEnvironment.load;
Expand All @@ -367,10 +370,30 @@ const resolveOtlpEndpoints = Effect.gen(function* () {

const environment = yield* DesktopEnvironment.DesktopEnvironment;
const persisted = yield* readPersistedObservabilitySettings;
const signalExport: SignalExport = {
protocol: environment.otlpProtocol,
headers: Option.getOrUndefined(environment.otlpHeaders),
exportIntervalMs: environment.otlpExportIntervalMs,
};
return {
traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl,
metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl,
logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl,
traces: OtelEnvironment.resolveSignalEndpoint(
otel,
"traces",
{ url: Option.getOrUndefined(environment.otlpTracesUrl), export: signalExport },
persisted.otlpTracesUrl,
),
metrics: OtelEnvironment.resolveSignalEndpoint(
otel,
"metrics",
{ url: Option.getOrUndefined(environment.otlpMetricsUrl), export: signalExport },
persisted.otlpMetricsUrl,
),
logs: OtelEnvironment.resolveSignalEndpoint(
otel,
"logs",
{ url: Option.getOrUndefined(environment.otlpLogsUrl), export: signalExport },
persisted.otlpLogsUrl,
),
warnings: otel.warnings,
resourceAttributes: otel.resourceAttributes,
};
Expand Down Expand Up @@ -602,8 +625,6 @@ const telemetryLayer = Layer.unwrap(
Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const endpoints = yield* resolveOtlpEndpoints;
const headers = Option.getOrUndefined(environment.otlpHeaders);
const serializationLayer = otlpSerializationLayer(environment.otlpProtocol);
const resource = {
serviceName: "desktop",
attributes: {
Expand All @@ -629,14 +650,19 @@ const telemetryLayer = Layer.unwrap(
: [
Logger.consolePretty(),
OtlpLogger.make({
url: endpoints.logs,
exportInterval: `${environment.otlpExportIntervalMs} millis`,
headers,
url: endpoints.logs.url,
exportInterval: `${endpoints.logs.export.exportIntervalMs} millis`,
headers: endpoints.logs.export.headers,
resource,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provide(serializationLayer));
).pipe(
Layer.provide(OtlpExporter.layerFlusher),
Layer.provide(
otlpSerializationLayer(endpoints.logs?.export.protocol ?? environment.otlpProtocol),
),
);

const tracerLayer = Layer.unwrap(
Effect.gen(function* () {
Expand All @@ -651,11 +677,11 @@ const telemetryLayer = Layer.unwrap(
endpoints.traces === undefined
? undefined
: yield* OtlpTracer.make({
url: endpoints.traces,
exportInterval: `${environment.otlpExportIntervalMs} millis`,
headers,
url: endpoints.traces.url,
exportInterval: `${endpoints.traces.export.exportIntervalMs} millis`,
headers: endpoints.traces.export.headers,
resource,
}).pipe(Effect.provide(serializationLayer));
}).pipe(Effect.provide(otlpSerializationLayer(endpoints.traces.export.protocol)));
const tracer = yield* makeLocalFileTracer({
filePath: tracePath,
maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
Expand All @@ -679,11 +705,11 @@ const telemetryLayer = Layer.unwrap(
// endpoints.metrics === undefined
// ? Layer.empty
// : OtlpMetrics.layer({
// url: endpoints.metrics,
// exportInterval: `${environment.otlpExportIntervalMs} millis`,
// headers,
// url: endpoints.metrics.url,
// exportInterval: `${endpoints.metrics.export.exportIntervalMs} millis`,
// headers: endpoints.metrics.export.headers,
// resource,
// }).pipe(Layer.provide(serializationLayer));
// }).pipe(Layer.provide(otlpSerializationLayer(endpoints.metrics.export.protocol)));

// Logged once the loggers above are installed, so the warnings use them.
const otelWarningsLayer = Layer.effectDiscard(
Expand Down
76 changes: 73 additions & 3 deletions apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,12 +780,11 @@ describe("DesktopBackendConfiguration", () => {
const config = yield* configuration.resolveWsl({ port: 5050, distro: null });

// No settings.json exists here: the endpoints come from the desktop
// process's env, which a WSL child cannot inherit, so the bootstrap
// has to carry them or log export stays off inside the distro.
// environment, and the bootstrap carries them for a WSL child that
// lacks the variables.
assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces");
assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics");
assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs");
assert.notInclude(config.env.WSLENV ?? "", "T3CODE_OTLP_LOGS_URL");
}).pipe(
Effect.provide(
DesktopBackendConfiguration.layer.pipe(
Expand Down Expand Up @@ -996,6 +995,73 @@ describe("DesktopBackendConfiguration", () => {
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect(
"resolveWsl carries the standard OTLP endpoint, headers, and protocol into the distro",
() =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-backend-config-test-",
});

const standard = {
OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com:4318/base?api_key=secret",
OTEL_EXPORTER_OTLP_LOGS_HEADERS: "authorization=Bearer%20token",
T3CODE_OTLP_TRACES_URL: "http://t3.example.com:4318/v1/traces",
};
const previousWslEnv = process.env.WSLENV;
// A developer's own OTLP variables would be forwarded too.
const ambientOtel = Object.entries(process.env).filter(
([name]) => name.startsWith("OTEL_") || name.startsWith("T3CODE_OTLP_"),
);
try {
for (const [name] of ambientOtel) delete process.env[name];
delete process.env.WSLENV;
Object.assign(process.env, standard);

yield* Effect.gen(function* () {
const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
const config = yield* configuration.resolveWsl({ port: 5050, distro: null });

assert.equal(
config.env.OTEL_EXPORTER_OTLP_ENDPOINT,
"https://collector.example.com:4318/base?api_key=secret",
);
assert.equal(
config.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS,
"authorization=Bearer%20token",
);
// Without a flag, WSL passes the values through untranslated.
const wslEnv = (config.env.WSLENV ?? "").split(":");
assert.include(wslEnv, "OTEL_EXPORTER_OTLP_ENDPOINT");
assert.include(wslEnv, "OTEL_EXPORTER_OTLP_LOGS_HEADERS");
assert.equal(config.env.T3CODE_OTLP_TRACES_URL, "http://t3.example.com:4318/v1/traces");
assert.include(wslEnv, "T3CODE_OTLP_TRACES_URL");
}).pipe(
Effect.provide(
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(
DesktopWslEnvironment.layerTest({
isAvailable: true,
windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"),
getDistroIp: () => Option.some("172.27.0.99"),
}),
),
Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })),
),
),
);
} finally {
for (const name of Object.keys(standard)) delete process.env[name];
restoreEnv("WSLENV", previousWslEnv);
for (const [name, value] of ambientOtel) restoreEnv(name, value);
}
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -1008,7 +1074,10 @@ describe("DesktopBackendConfiguration", () => {
const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;
const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS;
const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL;
// A developer's own OTEL_* variables would be forwarded too.
const ambientOtel = Object.entries(process.env).filter(([name]) => name.startsWith("OTEL_"));
try {
for (const [name] of ambientOtel) delete process.env[name];
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";
Expand Down Expand Up @@ -1065,6 +1134,7 @@ describe("DesktopBackendConfiguration", () => {
restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey);
restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders);
restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol);
for (const [name, value] of ambientOtel) restoreEnv(name, value);
}
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
Expand Down
Loading
Loading