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 @@...@@ -1,6 +1,7 @@
1import * as Http from "node:http";1import * as Http from "node:http";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it, vi } from "@effect/vitest";3import { assert, it, vi } from "@effect/vitest";
4import type { OrchestrationReadModel } from "@t3tools/contracts";
4import * as ConfigProvider from "effect/ConfigProvider";5import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
...@@ -9,9 +10,10 @@ import { FetchHttpClient } from "effect/unstable/http";...@@ -9,9 +10,10 @@ import { FetchHttpClient } from "effect/unstable/http";
9import { beforeEach } from "vitest";10import { beforeEach } from "vitest";
10import { NetService } from "@t3tools/shared/Net";11import { NetService } from "@t3tools/shared/Net";
1112
12import { CliConfig, t3Cli, type CliConfigShape } from "./main";13import { CliConfig, recordStartupHeartbeat, t3Cli, type CliConfigShape } from "./main";
13import { ServerConfig, type ServerConfigShape } from "./config";14import { ServerConfig, type ServerConfigShape } from "./config";
14import { Open, type OpenShape } from "./open";15import { Open, type OpenShape } from "./open";
16import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
15import { AnalyticsService } from "./telemetry/Services/AnalyticsService";17import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
16import { Server, type ServerShape } from "./wsServer";18import { Server, type ServerShape } from "./wsServer";
1719
...@@ -231,6 +233,43 @@ it.layer(testLayer)("server CLI command", (it) => {...@@ -231,6 +233,43 @@ it.layer(testLayer)("server CLI command", (it) => {
231 }),233 }),
232 );234 );
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
234 it.effect("does not start server for invalid --mode values", () =>273 it.effect("does not start server for invalid --mode values", () =>
235 Effect.gen(function* () {274 Effect.gen(function* () {
236 yield* runCli(["--mode", "invalid"]);275 yield* runCli(["--mode", "invalid"]);
apps/server/src/main.ts+28
...@@ -20,10 +20,12 @@ import { fixPath, resolveStateDir } from "./os-jank";...@@ -20,10 +20,12 @@ import { fixPath, resolveStateDir } from "./os-jank";
20import { Open } from "./open";20import { Open } from "./open";
21import * as SqlitePersistence from "./persistence/Layers/Sqlite";21import * as SqlitePersistence from "./persistence/Layers/Sqlite";
22import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";22import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
23import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
23import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";24import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";
24import { Server } from "./wsServer";25import { Server } from "./wsServer";
25import { ServerLoggerLive } from "./serverLogger";26import { ServerLoggerLive } from "./serverLogger";
26import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";27import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
28import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
2729
28export class StartupError extends Data.TaggedError("StartupError")<{30export class StartupError extends Data.TaggedError("StartupError")<{
29 readonly message: string;31 readonly message: string;
...@@ -208,6 +210,31 @@ const isWildcardHost = (host: string | undefined): boolean =>...@@ -208,6 +210,31 @@ const isWildcardHost = (host: string | undefined): boolean =>
208const formatHostForUrl = (host: string): string =>210const formatHostForUrl = (host: string): string =>
209 host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;211 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
211const makeServerProgram = (input: CliInput) =>238const makeServerProgram = (input: CliInput) =>
212 Effect.gen(function* () {239 Effect.gen(function* () {
213 const cliConfig = yield* CliConfig;240 const cliConfig = yield* CliConfig;
...@@ -227,6 +254,7 @@ const makeServerProgram = (input: CliInput) =>...@@ -227,6 +254,7 @@ const makeServerProgram = (input: CliInput) =>
227 }254 }
228255
229 yield* start;256 yield* start;
257 yield* Effect.forkChild(recordStartupHeartbeat);
230258
231 const localUrl = `http://localhost:${config.port}`;259 const localUrl = `http://localhost:${config.port}`;
232 const bindUrl =260 const bindUrl =
apps/server/src/telemetry/Layers/AnalyticsService.test.ts+8
...@@ -18,6 +18,7 @@ interface RecordedBatchRequest {...@@ -18,6 +18,7 @@ interface RecordedBatchRequest {
18 readonly event?: string;18 readonly event?: string;
19 readonly properties?: {19 readonly properties?: {
20 readonly index?: number;20 readonly index?: number;
21 readonly clientType?: string;
21 };22 };
22 }>;23 }>;
23 } | null;24 } | null;
...@@ -28,6 +29,7 @@ interface RecordedBatchBody {...@@ -28,6 +29,7 @@ interface RecordedBatchBody {
28 readonly event?: string;29 readonly event?: string;
29 readonly properties?: {30 readonly properties?: {
30 readonly index?: number;31 readonly index?: number;
32 readonly clientType?: string;
31 };33 };
32 }>;34 }>;
33}35}
...@@ -109,6 +111,12 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {...@@ -109,6 +111,12 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
109 sorted,111 sorted,
110 Array.from({ length: 45 }, (_, index) => index),112 Array.from({ length: 45 }, (_, index) => index),
111 );113 );
114 assert.equal(
115 batchRequests.every((request) =>
116 request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"),
117 ),
118 true,
119 );
112 }),120 }),
113 );121 );
114});122});
apps/server/src/telemetry/Layers/AnalyticsService.ts+4
...@@ -10,6 +10,7 @@...@@ -10,6 +10,7 @@
10import { Config, DateTime, Effect, Layer, Ref } from "effect";10import { Config, DateTime, Effect, Layer, Ref } from "effect";
11import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";11import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
1212
13import { ServerConfig } from "../../config.ts";
13import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts";14import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts";
14import { getTelemetryIdentifier } from "../Identify.ts";15import { getTelemetryIdentifier } from "../Identify.ts";
15import { version } from "../../../package.json" with { type: "json" };16import { version } from "../../../package.json" with { type: "json" };
...@@ -37,8 +38,10 @@ const TelemetryEnvConfig = Config.all({...@@ -37,8 +38,10 @@ const TelemetryEnvConfig = Config.all({
37const makeAnalyticsService = Effect.gen(function* () {38const makeAnalyticsService = Effect.gen(function* () {
38 const telemetryConfig = yield* TelemetryEnvConfig.asEffect();39 const telemetryConfig = yield* TelemetryEnvConfig.asEffect();
39 const httpClient = yield* HttpClient.HttpClient;40 const httpClient = yield* HttpClient.HttpClient;
41 const serverConfig = yield* ServerConfig;
40 const identifier = yield* getTelemetryIdentifier;42 const identifier = yield* getTelemetryIdentifier;
41 const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]);43 const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]);
44 const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client";
4245
43 const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) =>46 const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) =>
44 Effect.flatMap(DateTime.now, (now) =>47 Effect.flatMap(DateTime.now, (now) =>
...@@ -83,6 +86,7 @@ const makeAnalyticsService = Effect.gen(function* () {...@@ -83,6 +86,7 @@ const makeAnalyticsService = Effect.gen(function* () {
83 wsl: process.env.WSL_DISTRO_NAME,86 wsl: process.env.WSL_DISTRO_NAME,
84 arch: process.arch,87 arch: process.arch,
85 t3CodeVersion: version,88 t3CodeVersion: version,
89 clientType,
86 },90 },
87 timestamp: event.capturedAt,91 timestamp: event.capturedAt,
88 })),92 })),