History

Add anonymous PostHog telemetry for provider lifecycle events

- introduce `AnalyticsService` with buffered PostHog batch flush - add hashed telemetry identifier from Codex account id or anonymous local id - record provider session/turn lifecycle events and flush on stop-all - wire telemetry layer into server runtime and update tests with no-op analytics

Julius Marminge committed 8 months ago

Commit ec3778b1 parent 8c5b8d312 files · +337 −7Browse files

Unified · Split

12 files
  1. apps/server/integration/OrchestrationEngineHarness.integration.ts +13 −0
  2. apps/server/integration/providerService.integration.test.ts +5 −0
  3. apps/server/src/index.ts +4 −0
  4. apps/server/src/main.test.ts +5 −0
  5. apps/server/src/provider/Layers/ProviderService.test.ts +11 −0
  6. apps/server/src/provider/Layers/ProviderService.ts +47 −0
  7. apps/server/src/serverLayers.ts +2 −1
  8. apps/server/src/telemetry/Identify.ts +77 −0
  9. apps/server/src/telemetry/Layers/AnalyticsService.ts +137 −0
  10. apps/server/src/telemetry/Services/AnalyticsService.ts +28 −0
  11. apps/server/src/wsServer.test.ts +5 −5
  12. apps/server/src/wsServer.ts +3 −1
apps/server/integration/OrchestrationEngineHarness.integration.ts+13−0
@@ -40,6 +40,7 @@ import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService. · 39 unmodified lines
4040 import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts";
4141 import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts";
4242 import { ProviderService } from "../src/provider/Services/ProviderService.ts";
43+import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
4344 import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
4445 import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
4546 import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
@@ -252,10 +253,22 @@ export const makeOrchestrationIntegrationHarness = ( · 206 unmodified lines
252253 ? makeProviderServiceLive().pipe(
253254 Layer.provide(providerSessionDirectoryLayer),
254255 Layer.provide(realCodexRegistry),
256+ Layer.provide(
257+ Layer.succeed(AnalyticsService, {
258+ record: () => Effect.void,
259+ flush: Effect.void,
260+ }),
261+ ),
255262 )
256263 : makeProviderServiceLive().pipe(
257264 Layer.provide(providerSessionDirectoryLayer),
258265 Layer.provide(fakeRegistry!),
266+ Layer.provide(
267+ Layer.succeed(AnalyticsService, {
268+ record: () => Effect.void,
269+ flush: Effect.void,
270+ }),
271+ ),
259272 );
260273
261274 const runtimeServicesLayer = Layer.mergeAll(
apps/server/integration/providerService.integration.test.ts+5−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 ProviderService,
1313 type ProviderServiceShape,
1414 } from "../src/provider/Services/ProviderService.ts";
15+import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
1516 import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts";
1617 import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";
1718
@@ -59,6 +60,10 @@ const makeIntegrationFixture = Effect.gen(function* () { · 41 unmodified lines
5960 const shared = Layer.mergeAll(
6061 directoryLayer,
6162 Layer.succeed(ProviderAdapterRegistry, registry),
63+ Layer.succeed(AnalyticsService, {
64+ record: () => Effect.void,
65+ flush: Effect.void,
66+ }),
6267 ).pipe(Layer.provide(SqlitePersistenceMemory));
6368
6469 const layer = makeProviderServiceLive().pipe(Layer.provide(shared));
apps/server/src/index.ts+4−0
@@ -9,13 +9,17 @@ import { Command } from "effect/unstable/cli"; · 8 unmodified lines
99 import { version } from "../package.json" with { type: "json" };
1010 import { ServerLive } from "./wsServer";
1111 import { NetService } from "@t3tools/shared/Net";
12+import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
13+import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
1214
1315 const RuntimeLayer = Layer.empty.pipe(
1416 Layer.provideMerge(CliConfig.layer),
17+ Layer.provideMerge(AnalyticsServiceLayerLive),
1518 Layer.provideMerge(ServerLive),
1619 Layer.provideMerge(OpenLive),
1720 Layer.provideMerge(NetService.layer),
1821 Layer.provideMerge(NodeServices.layer),
22+ Layer.provideMerge(NodeHttpClient.layerUndici),
1923 );
2024
2125 Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);
apps/server/src/main.test.ts+5−0
@@ -11,6 +11,7 @@ import { NetService } from "@t3tools/shared/Net"; · 10 unmodified lines
1111 import { CliConfig, t3Cli, type CliConfigShape } from "./main";
1212 import { ServerConfig, type ServerConfigShape } from "./config";
1313 import { Open, type OpenShape } from "./open";
14+import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
1415 import { Server, type ServerShape } from "./wsServer";
1516
1617 const start = vi.fn(() => undefined);
@@ -47,6 +48,10 @@ const testLayer = Layer.mergeAll( · 30 unmodified lines
4748 openBrowser: (_target: string) => Effect.void,
4849 openInEditor: () => Effect.void,
4950 } satisfies OpenShape),
51+ Layer.succeed(AnalyticsService, {
52+ record: () => Effect.void,
53+ flush: Effect.void,
54+ }),
5055 NodeServices.layer,
5156 );
5257
apps/server/src/provider/Layers/ProviderService.test.ts+11−0
@@ -42,6 +42,7 @@ import { · 41 unmodified lines
4242 makeSqlitePersistenceLive,
4343 SqlitePersistenceMemory,
4444 } from "../../persistence/Layers/Sqlite.ts";
45+import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
4546
4647 const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
4748 const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
@@ -214,6 +215,11 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { · 166 unmodified lines
214215 const sleep = (ms: number) =>
215216 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
216217
218+const noopAnalyticsLayer = Layer.succeed(AnalyticsService, {
219+ record: () => Effect.void,
220+ flush: Effect.void,
221+});
222+
217223 function makeProviderServiceLayer() {
218224 const codex = makeFakeCodexAdapter();
219225 const registry: typeof ProviderAdapterRegistry.Service = {
@@ -235,8 +241,10 @@ function makeProviderServiceLayer() { · 15 unmodified lines
235241 makeProviderServiceLive().pipe(
236242 Layer.provide(providerAdapterLayer),
237243 Layer.provide(directoryLayer),
244+ Layer.provideMerge(noopAnalyticsLayer),
238245 ),
239246 directoryLayer,
247+
240248 runtimeRepositoryLayer,
241249 NodeServices.layer,
242250 ),
@@ -280,6 +288,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( · 37 unmodified lines
280288 const providerLayer = makeProviderServiceLive().pipe(
281289 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),
282290 Layer.provide(directoryLayer),
291+ Layer.provide(noopAnalyticsLayer),
283292 );
284293
285294 yield* Effect.gen(function* () {
@@ -338,6 +347,7 @@ it.effect( · 52 unmodified lines
338347 const firstProviderLayer = makeProviderServiceLive().pipe(
339348 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
340349 Layer.provide(firstDirectoryLayer),
350+ Layer.provide(noopAnalyticsLayer),
341351 );
342352
343353 const startedSession = yield* Effect.gen(function* () {
@@ -380,6 +390,7 @@ it.effect( · 36 unmodified lines
380390 const secondProviderLayer = makeProviderServiceLive().pipe(
381391 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
382392 Layer.provide(secondDirectoryLayer),
393+ Layer.provide(noopAnalyticsLayer),
383394 );
384395
385396 secondCodex.startSession.mockClear();
apps/server/src/provider/Layers/ProviderService.ts+47−0
@@ -31,6 +31,7 @@ import { · 30 unmodified lines
3131 type ProviderRuntimeBinding,
3232 } from "../Services/ProviderSessionDirectory.ts";
3333 import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
34+import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
3435
3536 export interface ProviderServiceLiveOptions {
3637 readonly canonicalEventLogPath?: string;
@@ -108,6 +109,7 @@ function readPersistedCwd( · 71 unmodified lines
108109
109110 const makeProviderService = (options?: ProviderServiceLiveOptions) =>
110111 Effect.gen(function* () {
112+ const analytics = yield* Effect.service(AnalyticsService);
111113 const canonicalEventLogger =
112114 options?.canonicalEventLogger ??
113115 (options?.canonicalEventLogPath !== undefined
@@ -178,6 +180,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 64 unmodified lines
178180 const existing = activeSessions.find((session) => session.threadId === input.binding.threadId);
179181 if (existing) {
180182 yield* upsertSessionBinding(existing, input.binding.threadId);
183+ yield* analytics.record("provider.session.recovered", {
184+ provider: existing.provider,
185+ strategy: "adopt-existing",
186+ hasResumeCursor: existing.resumeCursor !== undefined,
187+ });
181188 return { adapter, session: existing } as const;
182189 }
183190 }
@@ -206,6 +213,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 22 unmodified lines
206213 }
207214
208215 yield* upsertSessionBinding(resumed, input.binding.threadId);
216+ yield* analytics.record("provider.session.recovered", {
217+ provider: resumed.provider,
218+ strategy: "resume-thread",
219+ hasResumeCursor: resumed.resumeCursor !== undefined,
220+ });
209221 return { adapter, session: resumed } as const;
210222 });
211223
@@ -262,6 +274,13 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 50 unmodified lines
262274 }
263275
264276 yield* upsertSessionBinding(session, threadId);
277+ yield* analytics.record("provider.session.started", {
278+ provider: session.provider,
279+ hasResumeCursor: session.resumeCursor !== undefined,
280+ hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0,
281+ approvalPolicy: input.approvalPolicy,
282+ sandboxMode: input.sandboxMode,
283+ });
265284
266285 return session;
267286 });
@@ -301,6 +320,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 33 unmodified lines
301320 lastRuntimeEventAt: new Date().toISOString(),
302321 },
303322 });
323+ yield* analytics.record("provider.turn.sent", {
324+ provider: routed.adapter.provider,
325+ attachmentCount: input.attachments.length,
326+ hasInput: typeof input.input === "string" && input.input.trim().length > 0,
327+ });
304328 return turn;
305329 });
306330
@@ -317,6 +341,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 10 unmodified lines
317341 allowRecovery: true,
318342 });
319343 yield* routed.adapter.interruptTurn(routed.threadId, input.turnId);
344+ yield* analytics.record("provider.turn.interrupted", {
345+ provider: routed.adapter.provider,
346+ hasTurnId: input.turnId !== undefined,
347+ });
320348 });
321349
322350 const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) =>
@@ -332,6 +360,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 9 unmodified lines
332360 allowRecovery: true,
333361 });
334362 yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision);
363+ yield* analytics.record("provider.request.responded", {
364+ provider: routed.adapter.provider,
365+ decision: input.decision,
366+ });
335367 });
336368
337369 const respondToUserInput: ProviderServiceShape["respondToUserInput"] = (rawInput) =>
@@ -347,6 +379,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 9 unmodified lines
347379 allowRecovery: true,
348380 });
349381 yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers);
382+ yield* analytics.record("provider.user_input.responded", {
383+ provider: routed.adapter.provider,
384+ });
350385 });
351386
352387 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>
@@ -365,6 +400,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 12 unmodified lines
365400 yield* routed.adapter.stopSession(routed.threadId);
366401 }
367402 yield* directory.remove(input.threadId);
403+ yield* analytics.record("provider.session.stopped", {
404+ provider: routed.adapter.provider,
405+ wasActive: routed.isActive,
406+ });
368407 });
369408
370409 const listSessions: ProviderServiceShape["listSessions"] = () =>
@@ -429,6 +468,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 58 unmodified lines
429468 allowRecovery: true,
430469 });
431470 yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns);
471+ yield* analytics.record("provider.conversation.rolled_back", {
472+ provider: routed.adapter.provider,
473+ turns: input.numTurns,
474+ });
432475 });
433476
434477 const stopAll: ProviderServiceShape["stopAll"] = () =>
@@ -451,6 +494,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 16 unmodified lines
451494 ),
452495 ),
453496 ).pipe(Effect.asVoid);
497+ yield* analytics.record("provider.sessions.stopped_all", {
498+ sessionCount: threadIds.length,
499+ });
500+ yield* analytics.flush;
454501 });
455502
456503 return {
apps/server/src/serverLayers.ts+2−1
@@ -34,11 +34,12 @@ import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; · 33 unmodified lines
3434 import { GitServiceLive } from "./git/Layers/GitService";
3535 import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY";
3636 import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY";
37+import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
3738
3839 export function makeServerProviderLayer(): Layer.Layer<
3940 ProviderService,
4041 ProviderUnsupportedError,
41− SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem
42+ SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService
4243 > {
4344 return Effect.gen(function* () {
4445 const { stateDir } = yield* ServerConfig;
apps/server/src/telemetry/Identify.ts+77−0
@@ -0,0 +1,77 @@
1+import { Effect, FileSystem, Path, Random, Schema } from "effect";
2+import * as Crypto from "node:crypto";
3+import { homedir } from "node:os";
4+
5+const CodexAuthJsonSchema = Schema.Struct({
6+ tokens: Schema.Struct({
7+ account_id: Schema.String,
8+ }),
9+});
10+
11+class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", {
12+ message: Schema.String,
13+ cause: Schema.optional(Schema.Defect),
14+}) {}
15+
16+const hash = (value: string) =>
17+ Effect.try({
18+ try: () => Crypto.createHash("sha256").update(value).digest("hex"),
19+ catch: (error) =>
20+ new IdentifyUserError({
21+ message: "Failed to hash identifier",
22+ cause: error,
23+ }),
24+ });
25+
26+const getCodexAccountId = Effect.gen(function* () {
27+ const fileSystem = yield* FileSystem.FileSystem;
28+ const path = yield* Path.Path;
29+
30+ const authJsonPath = path.join(homedir(), ".codex", "auth.json");
31+ const authJson = yield* Effect.flatMap(
32+ fileSystem.readFileString(authJsonPath),
33+ Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)),
34+ );
35+
36+ return authJson.tokens.account_id;
37+});
38+
39+const upsertAnonymousId = Effect.gen(function* () {
40+ const fileSystem = yield* FileSystem.FileSystem;
41+ const path = yield* Path.Path;
42+
43+ const anonymousIdPath = path.join(homedir(), ".t3", "telemetry", "anonymous-id");
44+ const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe(
45+ Effect.catch(() =>
46+ Effect.gen(function* () {
47+ const randomId = yield* Random.nextUUIDv4;
48+ yield* fileSystem.writeFileString(anonymousIdPath, randomId);
49+ return randomId;
50+ }),
51+ ),
52+ );
53+
54+ return anonymousId;
55+});
56+
57+/**
58+ * getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value.
59+ * 1. ~/.codex/auth.json tokens.account_id
60+ * 2. ~/.t3/telemetry/anonymous-id
61+ */
62+export const getTelemetryIdentifier = Effect.gen(function* () {
63+ const codexAccountId = yield* getCodexAccountId;
64+ if (codexAccountId) {
65+ return yield* hash(codexAccountId);
66+ }
67+
68+ const anonymousId = yield* upsertAnonymousId;
69+ if (anonymousId) {
70+ return yield* hash(anonymousId);
71+ }
72+
73+ return null;
74+}).pipe(
75+ Effect.tapError((error) => Effect.logWarning("Failed to get identifier", { cause: error })),
76+ Effect.orElseSucceed(() => null),
77+);
apps/server/src/telemetry/Layers/AnalyticsService.ts+137−0
@@ -0,0 +1,137 @@
1+/**
2+ * AnalyticsServiceLive - Anonymous PostHog telemetry layer.
3+ *
4+ * Persists a random installation-scoped anonymous id to state dir, buffers
5+ * events in memory, and flushes batches to PostHog over Effect HttpClient.
6+ *
7+ * @module AnalyticsServiceLive
8+ */
9+
10+import { Config, DateTime, Effect, Layer, Ref } from "effect";
11+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
12+
13+import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts";
14+import { getTelemetryIdentifier } from "../Identify.ts";
15+
16+interface BufferedAnalyticsEvent {
17+ readonly event: string;
18+ readonly properties?: Readonly<Record<string, unknown>>;
19+ readonly capturedAt: string;
20+}
21+
22+const TelemetryEnvConfig = Config.all({
23+ posthogKey: Config.string("T3CODE_POSTHOG_KEY").pipe(
24+ Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"),
25+ ),
26+ posthogHost: Config.string("T3CODE_POSTHOG_HOST").pipe(
27+ Config.withDefault("https://us.i.posthog.com"),
28+ ),
29+ enabled: Config.boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)),
30+ flushBatchSize: Config.number("T3CODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)),
31+ maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe(
32+ Config.withDefault(1_000),
33+ ),
34+});
35+
36+const makeAnalyticsService = Effect.gen(function* () {
37+ const telemetryConfig = yield* TelemetryEnvConfig.asEffect();
38+ const httpClient = yield* HttpClient.HttpClient;
39+ const identifier = yield* getTelemetryIdentifier;
40+ const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]);
41+
42+ const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) =>
43+ Effect.flatMap(DateTime.now, (now) =>
44+ Ref.modify(bufferRef, (current) => {
45+ const appended = [
46+ ...current,
47+ {
48+ event,
49+ ...(properties ? { properties } : {}),
50+ capturedAt: DateTime.formatIso(now),
51+ } satisfies BufferedAnalyticsEvent,
52+ ];
53+
54+ const next =
55+ appended.length > telemetryConfig.maxBufferedEvents
56+ ? appended.slice(appended.length - telemetryConfig.maxBufferedEvents)
57+ : appended;
58+
59+ return [
60+ {
61+ size: next.length,
62+ dropped: next.length !== appended.length,
63+ } as const,
64+ next,
65+ ] as const;
66+ }),
67+ );
68+
69+ const sendBatch = (events: ReadonlyArray<BufferedAnalyticsEvent>) =>
70+ Effect.gen(function* () {
71+ if (!telemetryConfig.enabled || !identifier) return;
72+
73+ const payload = {
74+ api_key: telemetryConfig.posthogKey,
75+ batch: events.map((event) => ({
76+ event: event.event,
77+ distinct_id: identifier,
78+ properties: {
79+ ...event.properties,
80+ $process_person_profile: false,
81+ $lib: "t3code-server",
82+ },
83+ timestamp: event.capturedAt,
84+ })),
85+ };
86+
87+ yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe(
88+ HttpClientRequest.bodyJson(payload),
89+ Effect.flatMap(httpClient.execute),
90+ Effect.flatMap(HttpClientResponse.filterStatusOk),
91+ );
92+ });
93+
94+ const flush: AnalyticsServiceShape["flush"] = Effect.gen(function* () {
95+ const batch = yield* Ref.modify(bufferRef, (current) => {
96+ if (current.length === 0) {
97+ return [[] as ReadonlyArray<BufferedAnalyticsEvent>, current] as const;
98+ }
99+ const nextBatch = current.slice(0, telemetryConfig.flushBatchSize);
100+ const remaining = current.slice(nextBatch.length);
101+ return [nextBatch, remaining] as const;
102+ });
103+
104+ if (batch.length === 0) {
105+ return;
106+ }
107+
108+ yield* sendBatch(batch).pipe(
109+ Effect.catch((error) =>
110+ Ref.update(bufferRef, (current) => [...batch, ...current]).pipe(
111+ Effect.flatMap(() => Effect.fail(error)),
112+ ),
113+ ),
114+ );
115+ }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause })));
116+
117+ const record: AnalyticsServiceShape["record"] = Effect.fnUntraced(function* (event, properties) {
118+ if (!telemetryConfig.enabled || !identifier) return;
119+
120+ const enqueueResult = yield* enqueueBufferedEvent(event, properties);
121+ if (enqueueResult.dropped) {
122+ yield* Effect.logDebug("analytics buffer full; dropping oldest event", {
123+ size: enqueueResult.size,
124+ event,
125+ });
126+ }
127+ });
128+
129+ yield* Effect.forever(flush, { disableYield: true }).pipe(Effect.forkDetach);
130+
131+ return {
132+ record,
133+ flush,
134+ } satisfies AnalyticsServiceShape;
135+});
136+
137+export const AnalyticsServiceLayerLive = Layer.effect(AnalyticsService, makeAnalyticsService);
apps/server/src/telemetry/Services/AnalyticsService.ts+28−0
@@ -0,0 +1,28 @@
1+/**
2+ * AnalyticsService - Anonymous telemetry capture contract.
3+ *
4+ * Provides a best-effort event API for runtime telemetry and a strict
5+ * `captureImmediate` method for call sites that need explicit error handling.
6+ *
7+ * @module AnalyticsService
8+ */
9+import { Effect, ServiceMap } from "effect";
10+
11+export interface AnalyticsServiceShape {
12+ /**
13+ * Capture an event immediately; returns typed failure when capture fails.
14+ */
15+ readonly record: (
16+ event: string,
17+ properties?: Readonly<Record<string, unknown>>,
18+ ) => Effect.Effect<void, never>;
19+
20+ /**
21+ * Flush queued telemetry.
22+ */
23+ readonly flush: Effect.Effect<void, never>;
24+}
25+
26+export class AnalyticsService extends ServiceMap.Service<AnalyticsService, AnalyticsServiceShape>()(
27+ "t3/telemetry/Services/AnalyticsService",
28+) {}
apps/server/src/wsServer.test.ts+5−5
@@ -50,6 +50,8 @@ import type { GitCoreShape } from "./git/Services/GitCore.ts"; · 49 unmodified lines
5050 import { GitCore } from "./git/Services/GitCore.ts";
5151 import { GitCommandError, GitManagerError } from "./git/Errors.ts";
5252 import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
53+import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts";
54+import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
5355
5456 interface PendingMessages {
5557 queue: unknown[];
@@ -393,10 +395,7 @@ describe("WebSocket Server", () => { · 337 unmodified lines
393395 providerHealth?: ProviderHealthShape;
394396 open?: OpenShape;
395397 gitManager?: GitManagerShape;
396− gitCore?: Pick<
397− GitCoreShape,
398− "listBranches" | "initRepo" | "pullCurrentBranch"
399− >;
398+ gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">;
400399 terminalManager?: TerminalManagerShape;
401400 } = {},
402401 ): Promise<Http.Server> {
@@ -449,7 +448,9 @@ describe("WebSocket Server", () => { · 46 unmodified lines
449448 Layer.provideMerge(providerHealthLayer),
450449 Layer.provideMerge(openLayer),
451450 Layer.provideMerge(serverConfigLayer),
451+ Layer.provideMerge(AnalyticsServiceLayerLive),
452452 Layer.provideMerge(NodeServices.layer),
453+ Layer.provideMerge(NodeHttpClient.layerUndici),
453454 );
454455 const runtimeServices = await Effect.runPromise(
455456 Layer.build(dependenciesLayer).pipe(Scope.provide(scope)),
@@ -1620,7 +1621,6 @@ describe("WebSocket Server", () => { · 1164 unmodified lines
16201621 expect(pullResponse.result).toBeUndefined();
16211622 expect(pullResponse.error?.message).toContain("No upstream configured");
16221623 expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");
1623−
16241624 });
16251625
16261626 it("supports git.status over websocket", async () => {
apps/server/src/wsServer.ts+3−1
@@ -71,6 +71,7 @@ import { · 70 unmodified lines
7171 resolveAttachmentPathById,
7272 } from "./attachmentStore.ts";
7373 import { parseBase64DataUrl } from "./imageMime.ts";
74+import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
7475
7576 /**
7677 * ServerShape - Service API for server lifecycle control.
@@ -207,7 +208,8 @@ export type ServerRuntimeServices = · 130 unmodified lines
207208 | GitCore
208209 | TerminalManager
209210 | Keybindings
210− | Open;
211+ | Open
212+ | AnalyticsService;
211213
212214 export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(
213215 "ServerLifecycleError",