From 0f702fcd6271859212f83bc7f7100a339afe95da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Mar 2026 23:26:50 -0800 Subject: [PATCH] server heartbeat --- apps/server/src/main.test.ts | 41 ++++++++++++++++++- apps/server/src/main.ts | 28 +++++++++++++ .../telemetry/Layers/AnalyticsService.test.ts | 8 ++++ .../src/telemetry/Layers/AnalyticsService.ts | 4 ++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 15e363d07ceba85943ec4f63fc17fe9a3ebc1796..83976e3d4cf81a225ddfc5a658b93b8bb122c031 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -1,6 +1,7 @@ import * as Http from "node:http"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it, vi } from "@effect/vitest"; +import type { OrchestrationReadModel } from "@t3tools/contracts"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -9,9 +10,10 @@ import { FetchHttpClient } from "effect/unstable/http"; import { beforeEach } from "vitest"; import { NetService } from "@t3tools/shared/Net"; -import { CliConfig, t3Cli, type CliConfigShape } from "./main"; +import { CliConfig, recordStartupHeartbeat, t3Cli, type CliConfigShape } from "./main"; import { ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { Server, type ServerShape } from "./wsServer"; @@ -231,6 +233,43 @@ it.layer(testLayer)("server CLI command", (it) => { }), ); + it.effect("records a startup heartbeat with thread/project counts", () => + Effect.gen(function* () { + const recordTelemetry = vi.fn( + (_event: string, _properties?: Readonly>) => Effect.void, + ); + const getSnapshot = vi.fn(() => + Effect.succeed({ + snapshotSequence: 2, + projects: [{} as OrchestrationReadModel["projects"][number]], + threads: [ + {} as OrchestrationReadModel["threads"][number], + {} as OrchestrationReadModel["threads"][number], + ], + updatedAt: new Date(1).toISOString(), + } satisfies OrchestrationReadModel), + ); + + yield* recordStartupHeartbeat.pipe( + Effect.provideService(ProjectionSnapshotQuery, { + getSnapshot, + }), + Effect.provideService(AnalyticsService, { + record: recordTelemetry, + flush: Effect.void, + }), + ); + + assert.deepEqual(recordTelemetry.mock.calls[0], [ + "server.boot.heartbeat", + { + threadCount: 2, + projectCount: 1, + }, + ]); + }), + ); + it.effect("does not start server for invalid --mode values", () => Effect.gen(function* () { yield* runCli(["--mode", "invalid"]); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 3deb46aebc331a14a0188dd5f39da9f8fbdc61ac..0a33be0cbb2e56377162ed46d30d5c66a590d55e 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -20,10 +20,12 @@ import { fixPath, resolveStateDir } from "./os-jank"; import { Open } from "./open"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; import { Server } from "./wsServer"; import { ServerLoggerLive } from "./serverLogger"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; @@ -208,6 +210,31 @@ const isWildcardHost = (host: string | undefined): boolean => const formatHostForUrl = (host: string): string => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +export const recordStartupHeartbeat = Effect.gen(function* () { + const analytics = yield* AnalyticsService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe( + Effect.map((snapshot) => ({ + threadCount: snapshot.threads.length, + projectCount: snapshot.projects.length, + })), + Effect.catch((cause) => + Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe( + Effect.as({ + threadCount: 0, + projectCount: 0, + }), + ), + ), + ); + + yield* analytics.record("server.boot.heartbeat", { + threadCount, + projectCount, + }); +}); + const makeServerProgram = (input: CliInput) => Effect.gen(function* () { const cliConfig = yield* CliConfig; @@ -227,6 +254,7 @@ const makeServerProgram = (input: CliInput) => } yield* start; + yield* Effect.forkChild(recordStartupHeartbeat); const localUrl = `http://localhost:${config.port}`; const bindUrl = diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts index 84a0912bf7d3936354eab1dfc30f6c44e018bba2..adf7564ce4d7282fc20db665c807e7080beb62a4 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts @@ -18,6 +18,7 @@ interface RecordedBatchRequest { readonly event?: string; readonly properties?: { readonly index?: number; + readonly clientType?: string; }; }>; } | null; @@ -28,6 +29,7 @@ interface RecordedBatchBody { readonly event?: string; readonly properties?: { readonly index?: number; + readonly clientType?: string; }; }>; } @@ -109,6 +111,12 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { sorted, Array.from({ length: 45 }, (_, index) => index), ); + assert.equal( + batchRequests.every((request) => + request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"), + ), + true, + ); }), ); }); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index 96339606e5c1853101b2955dd5e837f1dc2f5471..297776f9dbd7ca442a4bf77c4c71e75484d5cb55 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -10,6 +10,7 @@ import { Config, DateTime, Effect, Layer, Ref } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { ServerConfig } from "../../config.ts"; import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; import { getTelemetryIdentifier } from "../Identify.ts"; import { version } from "../../../package.json" with { type: "json" }; @@ -37,8 +38,10 @@ const TelemetryEnvConfig = Config.all({ const makeAnalyticsService = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig.asEffect(); const httpClient = yield* HttpClient.HttpClient; + const serverConfig = yield* ServerConfig; const identifier = yield* getTelemetryIdentifier; const bufferRef = yield* Ref.make>([]); + const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; const enqueueBufferedEvent = (event: string, properties?: Readonly>) => Effect.flatMap(DateTime.now, (now) => @@ -83,6 +86,7 @@ const makeAnalyticsService = Effect.gen(function* () { wsl: process.env.WSL_DISTRO_NAME, arch: process.arch, t3CodeVersion: version, + clientType, }, timestamp: event.capturedAt, })), -- 2.54.0