authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 23:26:50-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:40:49-08:00
log0f702fcd6271859212f83bc7f7100a339afe95da
tree7b353d86ee82cc35ea83784fa67c13ed79644e35
parent13f2f9bc58e21671a2c9d71f049f13875d516f2f

server heartbeat


4 files changed, 80 insertions(+), 1 deletions(-)

apps/server/src/main.test.ts+40-1
......@@ -1,6 +1,7 @@
11import * as Http from "node:http";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import { assert, it, vi } from "@effect/vitest";
4import type { OrchestrationReadModel } from "@t3tools/contracts";
45import * as ConfigProvider from "effect/ConfigProvider";
56import * as Effect from "effect/Effect";
67import * as Layer from "effect/Layer";
......@@ -9,9 +10,10 @@ import { FetchHttpClient } from "effect/unstable/http";
910import { beforeEach } from "vitest";
1011import { NetService } from "@t3tools/shared/Net";
1112
12import { CliConfig, t3Cli, type CliConfigShape } from "./main";
13import { CliConfig, recordStartupHeartbeat, t3Cli, type CliConfigShape } from "./main";
1314import { ServerConfig, type ServerConfigShape } from "./config";
1415import { Open, type OpenShape } from "./open";
16import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
1517import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
1618import { Server, type ServerShape } from "./wsServer";
1719
......@@ -231,6 +233,43 @@ it.layer(testLayer)("server CLI command", (it) => {
231233 }),
232234 );
233235
236 it.effect("records a startup heartbeat with thread/project counts", () =>
237 Effect.gen(function* () {
238 const recordTelemetry = vi.fn(
239 (_event: string, _properties?: Readonly<Record<string, unknown>>) => Effect.void,
240 );
241 const getSnapshot = vi.fn(() =>
242 Effect.succeed({
243 snapshotSequence: 2,
244 projects: [{} as OrchestrationReadModel["projects"][number]],
245 threads: [
246 {} as OrchestrationReadModel["threads"][number],
247 {} as OrchestrationReadModel["threads"][number],
248 ],
249 updatedAt: new Date(1).toISOString(),
250 } satisfies OrchestrationReadModel),
251 );
252
253 yield* recordStartupHeartbeat.pipe(
254 Effect.provideService(ProjectionSnapshotQuery, {
255 getSnapshot,
256 }),
257 Effect.provideService(AnalyticsService, {
258 record: recordTelemetry,
259 flush: Effect.void,
260 }),
261 );
262
263 assert.deepEqual(recordTelemetry.mock.calls[0], [
264 "server.boot.heartbeat",
265 {
266 threadCount: 2,
267 projectCount: 1,
268 },
269 ]);
270 }),
271 );
272
234273 it.effect("does not start server for invalid --mode values", () =>
235274 Effect.gen(function* () {
236275 yield* runCli(["--mode", "invalid"]);
apps/server/src/main.ts+28
......@@ -20,10 +20,12 @@ import { fixPath, resolveStateDir } from "./os-jank";
2020import { Open } from "./open";
2121import * as SqlitePersistence from "./persistence/Layers/Sqlite";
2222import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
23import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
2324import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";
2425import { Server } from "./wsServer";
2526import { ServerLoggerLive } from "./serverLogger";
2627import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
28import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
2729
2830export class StartupError extends Data.TaggedError("StartupError")<{
2931 readonly message: string;
......@@ -208,6 +210,31 @@ const isWildcardHost = (host: string | undefined): boolean =>
208210const formatHostForUrl = (host: string): string =>
209211 host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
210212
213export const recordStartupHeartbeat = Effect.gen(function* () {
214 const analytics = yield* AnalyticsService;
215 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
216
217 const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe(
218 Effect.map((snapshot) => ({
219 threadCount: snapshot.threads.length,
220 projectCount: snapshot.projects.length,
221 })),
222 Effect.catch((cause) =>
223 Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe(
224 Effect.as({
225 threadCount: 0,
226 projectCount: 0,
227 }),
228 ),
229 ),
230 );
231
232 yield* analytics.record("server.boot.heartbeat", {
233 threadCount,
234 projectCount,
235 });
236});
237
211238const makeServerProgram = (input: CliInput) =>
212239 Effect.gen(function* () {
213240 const cliConfig = yield* CliConfig;
......@@ -227,6 +254,7 @@ const makeServerProgram = (input: CliInput) =>
227254 }
228255
229256 yield* start;
257 yield* Effect.forkChild(recordStartupHeartbeat);
230258
231259 const localUrl = `http://localhost:${config.port}`;
232260 const bindUrl =
apps/server/src/telemetry/Layers/AnalyticsService.test.ts+8
......@@ -18,6 +18,7 @@ interface RecordedBatchRequest {
1818 readonly event?: string;
1919 readonly properties?: {
2020 readonly index?: number;
21 readonly clientType?: string;
2122 };
2223 }>;
2324 } | null;
......@@ -28,6 +29,7 @@ interface RecordedBatchBody {
2829 readonly event?: string;
2930 readonly properties?: {
3031 readonly index?: number;
32 readonly clientType?: string;
3133 };
3234 }>;
3335}
......@@ -109,6 +111,12 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
109111 sorted,
110112 Array.from({ length: 45 }, (_, index) => index),
111113 );
114 assert.equal(
115 batchRequests.every((request) =>
116 request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"),
117 ),
118 true,
119 );
112120 }),
113121 );
114122});
apps/server/src/telemetry/Layers/AnalyticsService.ts+4
......@@ -10,6 +10,7 @@
1010import { Config, DateTime, Effect, Layer, Ref } from "effect";
1111import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
1212
13import { ServerConfig } from "../../config.ts";
1314import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts";
1415import { getTelemetryIdentifier } from "../Identify.ts";
1516import { version } from "../../../package.json" with { type: "json" };
......@@ -37,8 +38,10 @@ const TelemetryEnvConfig = Config.all({
3738const makeAnalyticsService = Effect.gen(function* () {
3839 const telemetryConfig = yield* TelemetryEnvConfig.asEffect();
3940 const httpClient = yield* HttpClient.HttpClient;
41 const serverConfig = yield* ServerConfig;
4042 const identifier = yield* getTelemetryIdentifier;
4143 const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]);
44 const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client";
4245
4346 const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) =>
4447 Effect.flatMap(DateTime.now, (now) =>
......@@ -83,6 +86,7 @@ const makeAnalyticsService = Effect.gen(function* () {
8386 wsl: process.env.WSL_DISTRO_NAME,
8487 arch: process.arch,
8588 t3CodeVersion: version,
89 clientType,
8690 },
8791 timestamp: event.capturedAt,
8892 })),