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