| 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,6 +1,7 @@ |
| 1 | import * as Http from "node:http"; | 1 | import * as Http from "node:http"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { assert, it, vi } from "@effect/vitest"; | 3 | import { assert, it, vi } from "@effect/vitest"; |
| 4 | import type { OrchestrationReadModel } from "@t3tools/contracts"; | ||
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | 5 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; | 7 | import * 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"; |
| 9 | import { beforeEach } from "vitest"; | 10 | import { beforeEach } from "vitest"; |
| 10 | import { NetService } from "@t3tools/shared/Net"; | 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 | import { ServerConfig, type ServerConfigShape } from "./config"; | 14 | import { ServerConfig, type ServerConfigShape } from "./config"; |
| 14 | import { Open, type OpenShape } from "./open"; | 15 | import { Open, type OpenShape } from "./open"; |
| 16 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | ||
| 15 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | 17 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 16 | import { Server, type ServerShape } from "./wsServer"; | 18 | import { Server, type ServerShape } from "./wsServer"; |
| 17 | 19 | ||
| ... | @@ -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 | ); |
| 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 | 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"; |
| 20 | import { Open } from "./open"; | 20 | import { Open } from "./open"; |
| 21 | import * as SqlitePersistence from "./persistence/Layers/Sqlite"; | 21 | import * as SqlitePersistence from "./persistence/Layers/Sqlite"; |
| 22 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; | 22 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; |
| 23 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | ||
| 23 | import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; | 24 | import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; |
| 24 | import { Server } from "./wsServer"; | 25 | import { Server } from "./wsServer"; |
| 25 | import { ServerLoggerLive } from "./serverLogger"; | 26 | import { ServerLoggerLive } from "./serverLogger"; |
| 26 | import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; | 27 | import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; |
| 28 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | ||
| 27 | 29 | ||
| 28 | export class StartupError extends Data.TaggedError("StartupError")<{ | 30 | export 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 => |
| 208 | const formatHostForUrl = (host: string): string => | 210 | const formatHostForUrl = (host: string): string => |
| 209 | host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; | 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 | const makeServerProgram = (input: CliInput) => | 238 | const 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 | } |
| 228 | 255 | ||
| 229 | yield* start; | 256 | yield* start; |
| 257 | yield* Effect.forkChild(recordStartupHeartbeat); | ||
| 230 | 258 | ||
| 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 @@ |
| 10 | import { Config, DateTime, Effect, Layer, Ref } from "effect"; | 10 | import { Config, DateTime, Effect, Layer, Ref } from "effect"; |
| 11 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | 11 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 12 | 12 | ||
| 13 | import { ServerConfig } from "../../config.ts"; | ||
| 13 | import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; | 14 | import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; |
| 14 | import { getTelemetryIdentifier } from "../Identify.ts"; | 15 | import { getTelemetryIdentifier } from "../Identify.ts"; |
| 15 | import { version } from "../../../package.json" with { type: "json" }; | 16 | import { version } from "../../../package.json" with { type: "json" }; |
| ... | @@ -37,8 +38,10 @@ const TelemetryEnvConfig = Config.all({ | ... | @@ -37,8 +38,10 @@ const TelemetryEnvConfig = Config.all({ |
| 37 | const makeAnalyticsService = Effect.gen(function* () { | 38 | const 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"; | ||
| 42 | 45 | ||
| 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 | })), |