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
2 changes: 2 additions & 0 deletions apps/desktop/src/app/DesktopConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export const DesktopConfig = Config.all({
desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"),
desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"),
otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"),
otlpMetricsUrl: trimmedString("T3CODE_OTLP_METRICS_URL"),
otlpLogsUrl: trimmedString("T3CODE_OTLP_LOGS_URL"),
otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
Config.withDefault(10_000),
),
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ describe("DesktopEnvironment", () => {
VITE_DEV_SERVER_URL: "http://localhost:5173",
T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ",
T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ",
T3CODE_OTLP_METRICS_URL: " http://127.0.0.1:4318/v1/metrics ",
T3CODE_OTLP_LOGS_URL: " http://127.0.0.1:4318/v1/logs ",
T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500",
T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3",
T3CODE_OTLP_PROTOCOL: "http/protobuf",
Expand Down Expand Up @@ -89,6 +91,8 @@ describe("DesktopEnvironment", () => {
assert.deepEqual(environment.configuredBackendPort, Option.some(4949));
assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef"));
assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces"));
assert.deepEqual(environment.otlpMetricsUrl, Option.some("http://127.0.0.1:4318/v1/metrics"));
assert.deepEqual(environment.otlpLogsUrl, Option.some("http://127.0.0.1:4318/v1/logs"));
assert.equal(environment.otlpExportIntervalMs, 2500);
assert.deepEqual(
environment.otlpHeaders,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export class DesktopEnvironment extends Context.Service<
readonly configuredBackendPort: Option.Option<number>;
readonly commitHashOverride: Option.Option<string>;
readonly otlpTracesUrl: Option.Option<string>;
readonly otlpMetricsUrl: Option.Option<string>;
readonly otlpLogsUrl: Option.Option<string>;
readonly otlpExportIntervalMs: number;
readonly otlpHeaders: Option.Option<Record<string, string>>;
readonly otlpProtocol: OtlpProtocol;
Expand Down Expand Up @@ -257,6 +259,8 @@ const make = Effect.fn("desktop.environment.make")(function* (
configuredBackendPort: config.configuredBackendPort,
commitHashOverride: config.commitHashOverride,
otlpTracesUrl: config.otlpTracesUrl,
otlpMetricsUrl: config.otlpMetricsUrl,
otlpLogsUrl: config.otlpLogsUrl,
otlpExportIntervalMs: config.otlpExportIntervalMs,
otlpHeaders: config.otlpHeaders,
otlpProtocol: config.otlpProtocol,
Expand Down
165 changes: 164 additions & 1 deletion apps/desktop/src/app/DesktopObservability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";

import * as DesktopConfig from "./DesktopConfig.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
Expand Down Expand Up @@ -49,19 +51,69 @@ const environmentInput = (baseDir: string) =>
runningUnderArm64Translation: false,
}) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;

const makeEnvironmentLayer = (baseDir: string, isDevelopment = true) =>
const makeEnvironmentLayer = (
baseDir: string,
isDevelopment = true,
env: Readonly<Record<string, string | undefined>> = {},
) =>
DesktopEnvironment.layer(environmentInput(baseDir)).pipe(
Layer.provide(
Layer.mergeAll(
NodeServices.layer,
DesktopConfig.layerTest({
T3CODE_HOME: baseDir,
VITE_DEV_SERVER_URL: isDevelopment ? "http://127.0.0.1:5733" : undefined,
...env,
}),
),
),
);

interface ExportedRequest {
readonly url: string;
readonly headers: Readonly<Record<string, string>>;
readonly body: string;
}

/** Answers every export with a 200 and keeps what was posted for assertions. */
const collectorLayer = (requests: Array<ExportedRequest>) =>
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
requests.push({
url: request.url,
headers: request.headers,
body:
request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "",
});
return HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }));
}),
),
);

const encodeObservabilitySettingsFile = Schema.encodeSync(
Schema.fromJsonString(
Schema.Struct({ observability: Schema.Record(Schema.String, Schema.String) }),
),
);

const writeObservabilitySettings = Effect.fn(function* (
environmentLayer: ReturnType<typeof makeEnvironmentLayer>,
observability: Readonly<Record<string, string>>,
) {
const fileSystem = yield* FileSystem.FileSystem;
const { path, serverSettingsPath } = yield* Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
return environment;
}).pipe(Effect.provide(environmentLayer));
yield* fileSystem.makeDirectory(path.dirname(serverSettingsPath), { recursive: true });
yield* fileSystem.writeFileString(
serverSettingsPath,
encodeObservabilitySettingsFile({ observability }),
);
});

describe("DesktopObservability", () => {
it("advances a retained output offset instead of repeatedly copying a full head chunk", () => {
const maxBufferedBytes = 1024 * 1024;
Expand Down Expand Up @@ -328,4 +380,115 @@ describe("DesktopObservability", () => {
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
),
);

it.effect("exports main process log records to the configured logs endpoint", () => {
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_LOGS_URL: "https://collector.example.com/v1/logs",
T3CODE_OTLP_HEADERS: "x-scope=desktop",
});
const tracePath = yield* Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
return environment.path.join(environment.logDir, "desktop.trace.ndjson");
}).pipe(Effect.provide(environmentLayer));

yield* Effect.scoped(
Effect.logInfo("desktop log export").pipe(
Effect.withSpan("desktop-log-export-test"),
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.include(request?.body ?? "", "desktop log export");
assert.include(request?.body ?? "", "service.runtime");
assert.strictEqual(request?.headers["x-scope"], "desktop");

// The log record is the export now, so the same message must not also
// ride along as an event on the span.
const record = (yield* fileSystem.readFileString(tracePath))
.trim()
.split("\n")
.filter((line) => line.length > 0)
.map((line) => decodeTraceRecordLine(line))
.find((entry) => entry.name === "desktop-log-export-test");
assert.notEqual(record, undefined);
assert.lengthOf(record?.events ?? [], 0);
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
);
});

it.effect("reads every signal endpoint from Settings when the environment names none", () => {
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);
yield* writeObservabilitySettings(environmentLayer, {
otlpTracesUrl: "https://settings.example.com/v1/traces",
otlpLogsUrl: "https://settings.example.com/v1/logs",
// The main process records no metrics yet, so this endpoint must
// not produce a request.
otlpMetricsUrl: "https://settings.example.com/v1/metrics",
});

yield* Effect.scoped(
Effect.logInfo("desktop log export from settings").pipe(
Effect.withSpan("desktop-settings-export-test"),
Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
),
);

assert.deepEqual(requests.map((request) => request.url).toSorted(), [
"https://settings.example.com/v1/logs",
"https://settings.example.com/v1/traces",
]);
assert.include(
requests.find((request) => request.url.endsWith("/v1/logs"))?.body ?? "",
"desktop log export from settings",
);
assert.include(
requests.find((request) => request.url.endsWith("/v1/traces"))?.body ?? "",
"desktop-settings-export-test",
);
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
);
});

it.effect("stays off the network when no endpoint is configured", () => {
const requests: Array<ExportedRequest> = [];
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-observability-test-",
});

yield* Effect.scoped(
Effect.logInfo("desktop log stays local").pipe(
Effect.withSpan("desktop-offline-test"),
Effect.provide(
DesktopObservability.layer.pipe(Layer.provideMerge(makeEnvironmentLayer(baseDir))),
),
),
);

assert.lengthOf(requests, 0);
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
);
});
});
Loading
Loading