authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-09-19 00:33:50-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 21:33:50-07:00
log82059df1523d070b00ab6f45159115f626a4977e
tree9ed319aecdc08d6274f0770568d3ceb5e979a7e1
parent0f1b572b9865256dd61e245b4dd09f6a7b15f0e1
signature Signed by PGP key B5690EEEBB952194

feat(desktop): export main process telemetry over OTLP (#12520)

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

3 files changed, 311 insertions(+), 56 deletions(-)

apps/desktop/src/app/DesktopObservability.test.ts+190-1
...@@ -4,7 +4,10 @@ import { assert, describe, it } from "@effect/vitest";...@@ -4,7 +4,10 @@ import { assert, describe, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Metric from "effect/Metric";
7import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import * as HttpClient from "effect/unstable/http/HttpClient";
10import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
811
9import * as DesktopConfig from "./DesktopConfig.ts";12import * as DesktopConfig from "./DesktopConfig.ts";
10import * as DesktopEnvironment from "./DesktopEnvironment.ts";13import * as DesktopEnvironment from "./DesktopEnvironment.ts";
...@@ -49,7 +52,11 @@ const environmentInput = (baseDir: string) =>...@@ -49,7 +52,11 @@ const environmentInput = (baseDir: string) =>
49 runningUnderArm64Translation: false,52 runningUnderArm64Translation: false,
50 }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;53 }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
5154
52const makeEnvironmentLayer = (baseDir: string, isDevelopment = true) =>55const makeEnvironmentLayer = (
56 baseDir: string,
57 isDevelopment = true,
58 env: Readonly<Record<string, string | undefined>> = {},
59) =>
53 DesktopEnvironment.layer(environmentInput(baseDir)).pipe(60 DesktopEnvironment.layer(environmentInput(baseDir)).pipe(
54 Layer.provide(61 Layer.provide(
55 Layer.mergeAll(62 Layer.mergeAll(
...@@ -57,11 +64,57 @@ const makeEnvironmentLayer = (baseDir: string, isDevelopment = true) =>...@@ -57,11 +64,57 @@ const makeEnvironmentLayer = (baseDir: string, isDevelopment = true) =>
57 DesktopConfig.layerTest({64 DesktopConfig.layerTest({
58 T3CODE_HOME: baseDir,65 T3CODE_HOME: baseDir,
59 VITE_DEV_SERVER_URL: isDevelopment ? "http://127.0.0.1:5733" : undefined,66 VITE_DEV_SERVER_URL: isDevelopment ? "http://127.0.0.1:5733" : undefined,
67 ...env,
60 }),68 }),
61 ),69 ),
62 ),70 ),
63 );71 );
6472
73interface ExportedRequest {
74 readonly url: string;
75 readonly headers: Readonly<Record<string, string>>;
76 readonly body: string;
77}
78
79/** Answers every export with a 200 and keeps what was posted for assertions. */
80const collectorLayer = (requests: Array<ExportedRequest>) =>
81 Layer.succeed(
82 HttpClient.HttpClient,
83 HttpClient.make((request) =>
84 Effect.sync(() => {
85 requests.push({
86 url: request.url,
87 headers: request.headers,
88 body:
89 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "",
90 });
91 return HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }));
92 }),
93 ),
94 );
95
96const encodeObservabilitySettingsFile = Schema.encodeSync(
97 Schema.fromJsonString(
98 Schema.Struct({ observability: Schema.Record(Schema.String, Schema.String) }),
99 ),
100);
101
102const writeObservabilitySettings = Effect.fn(function* (
103 environmentLayer: ReturnType<typeof makeEnvironmentLayer>,
104 observability: Readonly<Record<string, string>>,
105) {
106 const fileSystem = yield* FileSystem.FileSystem;
107 const { path, serverSettingsPath } = yield* Effect.gen(function* () {
108 const environment = yield* DesktopEnvironment.DesktopEnvironment;
109 return environment;
110 }).pipe(Effect.provide(environmentLayer));
111 yield* fileSystem.makeDirectory(path.dirname(serverSettingsPath), { recursive: true });
112 yield* fileSystem.writeFileString(
113 serverSettingsPath,
114 encodeObservabilitySettingsFile({ observability }),
115 );
116});
117
65describe("DesktopObservability", () => {118describe("DesktopObservability", () => {
66 it("advances a retained output offset instead of repeatedly copying a full head chunk", () => {119 it("advances a retained output offset instead of repeatedly copying a full head chunk", () => {
67 const maxBufferedBytes = 1024 * 1024;120 const maxBufferedBytes = 1024 * 1024;
...@@ -328,4 +381,140 @@ describe("DesktopObservability", () => {...@@ -328,4 +381,140 @@ describe("DesktopObservability", () => {
328 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),381 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
329 ),382 ),
330 );383 );
384
385 it.effect("exports main process log records to the configured logs endpoint", () => {
386 const requests: Array<ExportedRequest> = [];
387 return Effect.gen(function* () {
388 const fileSystem = yield* FileSystem.FileSystem;
389 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
390 prefix: "t3-desktop-observability-test-",
391 });
392 const environmentLayer = makeEnvironmentLayer(baseDir, true, {
393 T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs",
394 T3CODE_OTLP_HEADERS: "x-scope=desktop",
395 });
396 const tracePath = yield* Effect.gen(function* () {
397 const environment = yield* DesktopEnvironment.DesktopEnvironment;
398 return environment.path.join(environment.logDir, "desktop.trace.ndjson");
399 }).pipe(Effect.provide(environmentLayer));
400
401 yield* Effect.scoped(
402 Effect.logInfo("desktop log export").pipe(
403 Effect.withSpan("desktop-log-export-test"),
404 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
405 ),
406 );
407
408 assert.lengthOf(requests, 1);
409 const [request] = requests;
410 assert.strictEqual(request?.url, "https://collector.example.com/v1/logs");
411 assert.include(request?.body ?? "", "desktop log export");
412 assert.include(request?.body ?? "", "service.runtime");
413 assert.strictEqual(request?.headers["x-scope"], "desktop");
414
415 // The log record is the export now, so the same message must not also
416 // ride along as an event on the span.
417 const record = (yield* fileSystem.readFileString(tracePath))
418 .trim()
419 .split("\n")
420 .filter((line) => line.length > 0)
421 .map((line) => decodeTraceRecordLine(line))
422 .find((entry) => entry.name === "desktop-log-export-test");
423 assert.notEqual(record, undefined);
424 assert.lengthOf(record?.events ?? [], 0);
425 }).pipe(
426 Effect.scoped,
427 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
428 );
429 });
430
431 it.effect("exports main process metrics to the configured metrics endpoint", () => {
432 const requests: Array<ExportedRequest> = [];
433 return Effect.gen(function* () {
434 const fileSystem = yield* FileSystem.FileSystem;
435 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
436 prefix: "t3-desktop-observability-test-",
437 });
438 const environmentLayer = makeEnvironmentLayer(baseDir, true, {
439 T3CODE_OTLP_METRICS_URL: "https://collector.example.com/v1/metrics",
440 });
441
442 yield* Effect.scoped(
443 Metric.update(Metric.counter("desktop_metrics_export_test_total"), 1).pipe(
444 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
445 ),
446 );
447
448 assert.lengthOf(requests, 1);
449 assert.strictEqual(requests[0]?.url, "https://collector.example.com/v1/metrics");
450 assert.include(requests[0]?.body ?? "", "desktop_metrics_export_test_total");
451 }).pipe(
452 Effect.scoped,
453 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
454 );
455 });
456
457 it.effect("reads every signal endpoint from Settings when the environment names none", () => {
458 const requests: Array<ExportedRequest> = [];
459 return Effect.gen(function* () {
460 const fileSystem = yield* FileSystem.FileSystem;
461 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
462 prefix: "t3-desktop-observability-test-",
463 });
464 const environmentLayer = makeEnvironmentLayer(baseDir);
465 yield* writeObservabilitySettings(environmentLayer, {
466 otlpLogsUrl: "https://settings.example.com/v1/logs",
467 otlpMetricsUrl: "https://settings.example.com/v1/metrics",
468 });
469
470 yield* Effect.scoped(
471 Effect.gen(function* () {
472 yield* Effect.logInfo("desktop log export from settings");
473 yield* Metric.update(Metric.counter("desktop_settings_export_test_total"), 1);
474 }).pipe(
475 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
476 ),
477 );
478
479 assert.deepEqual(requests.map((request) => request.url).toSorted(), [
480 "https://settings.example.com/v1/logs",
481 "https://settings.example.com/v1/metrics",
482 ]);
483 assert.include(
484 requests.find((request) => request.url.endsWith("/v1/logs"))?.body ?? "",
485 "desktop log export from settings",
486 );
487 assert.include(
488 requests.find((request) => request.url.endsWith("/v1/metrics"))?.body ?? "",
489 "desktop_settings_export_test_total",
490 );
491 }).pipe(
492 Effect.scoped,
493 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
494 );
495 });
496
497 it.effect("stays off the network when no endpoint is configured", () => {
498 const requests: Array<ExportedRequest> = [];
499 return Effect.gen(function* () {
500 const fileSystem = yield* FileSystem.FileSystem;
501 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
502 prefix: "t3-desktop-observability-test-",
503 });
504
505 yield* Effect.scoped(
506 Effect.logInfo("desktop log stays local").pipe(
507 Effect.withSpan("desktop-offline-test"),
508 Effect.provide(
509 DesktopObservability.layer.pipe(Layer.provideMerge(makeEnvironmentLayer(baseDir))),
510 ),
511 ),
512 );
513
514 assert.lengthOf(requests, 0);
515 }).pipe(
516 Effect.scoped,
517 Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))),
518 );
519 });
331});520});
apps/desktop/src/app/DesktopObservability.ts+114-55
...@@ -4,7 +4,10 @@ import {...@@ -4,7 +4,10 @@ import {
4 makeTraceSink,4 makeTraceSink,
5 otlpSerializationLayer,5 otlpSerializationLayer,
6} from "@t3tools/shared/observability";6} from "@t3tools/shared/observability";
7import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";7import {
8 parsePersistedServerObservabilitySettings,
9 type PersistedServerObservabilitySettings,
10} from "@t3tools/shared/serverSettings";
8import * as Context from "effect/Context";11import * as Context from "effect/Context";
9import * as DateTime from "effect/DateTime";12import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
...@@ -21,7 +24,7 @@ import * as Scope from "effect/Scope";...@@ -21,7 +24,7 @@ import * as Scope from "effect/Scope";
21import * as Semaphore from "effect/Semaphore";24import * as Semaphore from "effect/Semaphore";
22import * as SynchronizedRef from "effect/SynchronizedRef";25import * as SynchronizedRef from "effect/SynchronizedRef";
23import * as Tracer from "effect/Tracer";26import * as Tracer from "effect/Tracer";
24import { OtlpExporter, OtlpTracer } from "effect/unstable/observability";27import { OtlpExporter, OtlpLogger, OtlpMetrics, OtlpTracer } from "effect/unstable/observability";
2528
26import * as DesktopEnvironment from "./DesktopEnvironment.ts";29import * as DesktopEnvironment from "./DesktopEnvironment.ts";
2730
...@@ -326,28 +329,37 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio...@@ -326,28 +329,37 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio
326 } satisfies RotatingLogFileWriter;329 } satisfies RotatingLogFileWriter;
327});330});
328331
329const readPersistedOtlpTracesUrl: Effect.Effect<332const noPersistedObservabilitySettings: PersistedServerObservabilitySettings = {
330 Option.Option<string>,333 otlpTracesUrl: undefined,
334 otlpMetricsUrl: undefined,
335 otlpLogsUrl: undefined,
336};
337
338const readPersistedObservabilitySettings: Effect.Effect<
339 PersistedServerObservabilitySettings,
331 never,340 never,
332 FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment341 FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
333> = Effect.gen(function* () {342> = Effect.gen(function* () {
334 const fileSystem = yield* FileSystem.FileSystem;343 const fileSystem = yield* FileSystem.FileSystem;
335 const environment = yield* DesktopEnvironment.DesktopEnvironment;344 const environment = yield* DesktopEnvironment.DesktopEnvironment;
336 const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option);345 const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option);
337 if (Option.isNone(raw)) {346 return Option.isNone(raw)
338 return Option.none();347 ? noPersistedObservabilitySettings
339 }348 : parsePersistedServerObservabilitySettings(raw.value);
340
341 const parsed = parsePersistedServerObservabilitySettings(raw.value);
342 return Option.fromNullishOr(parsed.otlpTracesUrl);
343});349});
344350
345const resolveOtlpTracesUrl = Effect.gen(function* () {351/**
352 * Settings is read once for all three signals, so the main process cannot
353 * resolve traces against one revision of the file and logs against another.
354 */
355const resolveOtlpEndpoints = Effect.gen(function* () {
346 const environment = yield* DesktopEnvironment.DesktopEnvironment;356 const environment = yield* DesktopEnvironment.DesktopEnvironment;
347 if (Option.isSome(environment.otlpTracesUrl)) {357 const persisted = yield* readPersistedObservabilitySettings;
348 return environment.otlpTracesUrl;358 return {
349 }359 traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl,
350 return yield* readPersistedOtlpTracesUrl;360 metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl,
361 logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl,
362 };
351});363});
352364
353const writeDevelopmentConsoleOutput = (365const writeDevelopmentConsoleOutput = (
...@@ -567,53 +579,100 @@ const backendOutputLogFactoryLayer = Layer.effect(...@@ -567,53 +579,100 @@ const backendOutputLogFactoryLayer = Layer.effect(
567 }),579 }),
568);580);
569581
570const desktopLoggerLayer = Layer.mergeAll(582/**
571 Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }),583 * Logs, traces, and metrics for the main process, assembled together because
572 Layer.succeed(References.MinimumLogLevel, "Info"),584 * they share one read of the environment and Settings, and because a process
573);585 * gets exactly one logger set.
574586 */
575const tracerLayer = Layer.unwrap(587const telemetryLayer = Layer.unwrap(
576 Effect.gen(function* () {588 Effect.gen(function* () {
577 const environment = yield* DesktopEnvironment.DesktopEnvironment;589 const environment = yield* DesktopEnvironment.DesktopEnvironment;
578 const otlpTracesUrl = yield* resolveOtlpTracesUrl;590 const endpoints = yield* resolveOtlpEndpoints;
579 const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson");591 const headers = Option.getOrUndefined(environment.otlpHeaders);
580 const sink = yield* makeTraceSink({592 const serializationLayer = otlpSerializationLayer(environment.otlpProtocol);
581 filePath: tracePath,593 const resource = {
582 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,594 serviceName: "desktop",
583 maxFiles: DESKTOP_LOG_FILE_MAX_FILES,595 attributes: {
584 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,596 "service.runtime": "desktop",
585 });597 "service.mode": environment.isDevelopment ? "development" : "packaged",
586 const delegate = Option.isNone(otlpTracesUrl)598 },
587 ? undefined599 };
588 : yield* OtlpTracer.make({
589 url: otlpTracesUrl.value,
590 exportInterval: `${environment.otlpExportIntervalMs} millis`,
591 headers: Option.getOrUndefined(environment.otlpHeaders),
592 resource: {
593 serviceName: "desktop",
594 attributes: {
595 "service.runtime": "desktop",
596 "service.mode": environment.isDevelopment ? "development" : "packaged",
597 },
598 },
599 }).pipe(Effect.provide(otlpSerializationLayer(environment.otlpProtocol)));
600 const tracer = yield* makeLocalFileTracer({
601 filePath: tracePath,
602 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
603 maxFiles: DESKTOP_LOG_FILE_MAX_FILES,
604 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
605 sink,
606 ...(delegate ? { delegate } : {}),
607 });
608600
609 return Layer.succeed(Tracer.Tracer, tracer);601 // `Logger.layer` writes the whole logger set rather than adding to it, so
602 // every logger the main process wants has to be named in this one call.
603 // Splitting the OTLP logger back out into a layer of its own silently
604 // drops either it or the console output.
605 //
606 // Swapping `Logger.tracerLogger` out for the OTLP logger matches the
607 // server: both reach a collector, but the tracer logger covers only
608 // messages logged inside a recorded span and files them under traces,
609 // while the OTLP logger carries every message as a log record stamped
610 // with its trace and span ids. Keeping both would export every in-span
611 // message twice.
612 const loggerLayer = Logger.layer(
613 endpoints.logs === undefined
614 ? [Logger.consolePretty(), Logger.tracerLogger]
615 : [
616 Logger.consolePretty(),
617 OtlpLogger.make({
618 url: endpoints.logs,
619 exportInterval: `${environment.otlpExportIntervalMs} millis`,
620 headers,
621 resource,
622 }),
623 ],
624 { mergeWithExisting: false },
625 ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provide(serializationLayer));
626
627 const tracerLayer = Layer.unwrap(
628 Effect.gen(function* () {
629 const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson");
630 const sink = yield* makeTraceSink({
631 filePath: tracePath,
632 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
633 maxFiles: DESKTOP_LOG_FILE_MAX_FILES,
634 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
635 });
636 const delegate =
637 endpoints.traces === undefined
638 ? undefined
639 : yield* OtlpTracer.make({
640 url: endpoints.traces,
641 exportInterval: `${environment.otlpExportIntervalMs} millis`,
642 headers,
643 resource,
644 }).pipe(Effect.provide(serializationLayer));
645 const tracer = yield* makeLocalFileTracer({
646 filePath: tracePath,
647 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
648 maxFiles: DESKTOP_LOG_FILE_MAX_FILES,
649 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
650 sink,
651 ...(delegate ? { delegate } : {}),
652 });
653
654 return Layer.succeed(Tracer.Tracer, tracer);
655 }),
656 ).pipe(Layer.provide(OtlpExporter.layerFlusher));
657
658 const metricsLayer =
659 endpoints.metrics === undefined
660 ? Layer.empty
661 : OtlpMetrics.layer({
662 url: endpoints.metrics,
663 exportInterval: `${environment.otlpExportIntervalMs} millis`,
664 headers,
665 resource,
666 }).pipe(Layer.provide(serializationLayer));
667
668 return Layer.mergeAll(loggerLayer, tracerLayer, metricsLayer);
610 }),669 }),
611).pipe(Layer.provide(OtlpExporter.layerFlusher));670);
612671
613export const layer = Layer.mergeAll(672export const layer = Layer.mergeAll(
614 backendOutputLogFactoryLayer,673 backendOutputLogFactoryLayer,
615 desktopLoggerLayer,674 telemetryLayer,
616 tracerLayer,675 Layer.succeed(References.MinimumLogLevel, "Info"),
617 Layer.succeed(Tracer.MinimumTraceLevel, "Info"),676 Layer.succeed(Tracer.MinimumTraceLevel, "Info"),
618 Layer.succeed(References.TracerTimingEnabled, true),677 Layer.succeed(References.TracerTimingEnabled, true),
619);678);
docs/operations/observability.md+7
...@@ -522,6 +522,13 @@ It provides:...@@ -522,6 +522,13 @@ It provides:
522- optional OTLP log exporter522- optional OTLP log exporter
523- Effect trace-level and timing refs523- Effect trace-level and timing refs
524524
525The desktop main process is a second producer, assembled in
526`apps/desktop/src/app/DesktopObservability.ts`. It reads the same `T3CODE_OTLP_*` names and the same
527Settings entries as the backend it supervises, and covers work the backend cannot see: app startup,
528window and menu handling, backend supervision, and updates. It reports as service `desktop`
529regardless of `T3CODE_OTLP_SERVICE_NAME`, so a collector shows it alongside the backend rather than
530mixed into it.
531
525### Env Vars532### Env Vars
526533
527Local trace file:534Local trace file: