authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 17:42:53-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:40:49-08:00
logec3778bbf4a5d8726cff22bddfcba82cd1c6ebde
treeef2fb0f61750f7065b8ace19e92a15003b248724
parent8c5b8d3b720a82de1c77fe3aa32bde7a5ff36325

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

12 files changed, 337 insertions(+), 7 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+13
...@@ -40,6 +40,7 @@ import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService....@@ -40,6 +40,7 @@ import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.
40import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts";40import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts";
41import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts";41import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts";
42import { ProviderService } from "../src/provider/Services/ProviderService.ts";42import { ProviderService } from "../src/provider/Services/ProviderService.ts";
43import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
43import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";44import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
44import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";45import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
45import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";46import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
...@@ -252,10 +253,22 @@ export const makeOrchestrationIntegrationHarness = (...@@ -252,10 +253,22 @@ export const makeOrchestrationIntegrationHarness = (
252 ? makeProviderServiceLive().pipe(253 ? makeProviderServiceLive().pipe(
253 Layer.provide(providerSessionDirectoryLayer),254 Layer.provide(providerSessionDirectoryLayer),
254 Layer.provide(realCodexRegistry),255 Layer.provide(realCodexRegistry),
256 Layer.provide(
257 Layer.succeed(AnalyticsService, {
258 record: () => Effect.void,
259 flush: Effect.void,
260 }),
261 ),
255 )262 )
256 : makeProviderServiceLive().pipe(263 : makeProviderServiceLive().pipe(
257 Layer.provide(providerSessionDirectoryLayer),264 Layer.provide(providerSessionDirectoryLayer),
258 Layer.provide(fakeRegistry!),265 Layer.provide(fakeRegistry!),
266 Layer.provide(
267 Layer.succeed(AnalyticsService, {
268 record: () => Effect.void,
269 flush: Effect.void,
270 }),
271 ),
259 );272 );
260273
261 const runtimeServicesLayer = Layer.mergeAll(274 const runtimeServicesLayer = Layer.mergeAll(
apps/server/integration/providerService.integration.test.ts+5
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 ProviderService,12 ProviderService,
13 type ProviderServiceShape,13 type ProviderServiceShape,
14} from "../src/provider/Services/ProviderService.ts";14} from "../src/provider/Services/ProviderService.ts";
15import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
15import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts";16import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts";
16import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";17import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";
1718
...@@ -59,6 +60,10 @@ const makeIntegrationFixture = Effect.gen(function* () {...@@ -59,6 +60,10 @@ const makeIntegrationFixture = Effect.gen(function* () {
59 const shared = Layer.mergeAll(60 const shared = Layer.mergeAll(
60 directoryLayer,61 directoryLayer,
61 Layer.succeed(ProviderAdapterRegistry, registry),62 Layer.succeed(ProviderAdapterRegistry, registry),
63 Layer.succeed(AnalyticsService, {
64 record: () => Effect.void,
65 flush: Effect.void,
66 }),
62 ).pipe(Layer.provide(SqlitePersistenceMemory));67 ).pipe(Layer.provide(SqlitePersistenceMemory));
6368
64 const layer = makeProviderServiceLive().pipe(Layer.provide(shared));69 const layer = makeProviderServiceLive().pipe(Layer.provide(shared));
apps/server/src/index.ts+4
...@@ -9,13 +9,17 @@ import { Command } from "effect/unstable/cli";...@@ -9,13 +9,17 @@ import { Command } from "effect/unstable/cli";
9import { version } from "../package.json" with { type: "json" };9import { version } from "../package.json" with { type: "json" };
10import { ServerLive } from "./wsServer";10import { ServerLive } from "./wsServer";
11import { NetService } from "@t3tools/shared/Net";11import { NetService } from "@t3tools/shared/Net";
12import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
13import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
1214
13const RuntimeLayer = Layer.empty.pipe(15const RuntimeLayer = Layer.empty.pipe(
14 Layer.provideMerge(CliConfig.layer),16 Layer.provideMerge(CliConfig.layer),
17 Layer.provideMerge(AnalyticsServiceLayerLive),
15 Layer.provideMerge(ServerLive),18 Layer.provideMerge(ServerLive),
16 Layer.provideMerge(OpenLive),19 Layer.provideMerge(OpenLive),
17 Layer.provideMerge(NetService.layer),20 Layer.provideMerge(NetService.layer),
18 Layer.provideMerge(NodeServices.layer),21 Layer.provideMerge(NodeServices.layer),
22 Layer.provideMerge(NodeHttpClient.layerUndici),
19);23);
2024
21Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);25Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);
apps/server/src/main.test.ts+5
...@@ -11,6 +11,7 @@ import { NetService } from "@t3tools/shared/Net";...@@ -11,6 +11,7 @@ import { NetService } from "@t3tools/shared/Net";
11import { CliConfig, t3Cli, type CliConfigShape } from "./main";11import { CliConfig, t3Cli, type CliConfigShape } from "./main";
12import { ServerConfig, type ServerConfigShape } from "./config";12import { ServerConfig, type ServerConfigShape } from "./config";
13import { Open, type OpenShape } from "./open";13import { Open, type OpenShape } from "./open";
14import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
14import { Server, type ServerShape } from "./wsServer";15import { Server, type ServerShape } from "./wsServer";
1516
16const start = vi.fn(() => undefined);17const start = vi.fn(() => undefined);
...@@ -47,6 +48,10 @@ const testLayer = Layer.mergeAll(...@@ -47,6 +48,10 @@ const testLayer = Layer.mergeAll(
47 openBrowser: (_target: string) => Effect.void,48 openBrowser: (_target: string) => Effect.void,
48 openInEditor: () => Effect.void,49 openInEditor: () => Effect.void,
49 } satisfies OpenShape),50 } satisfies OpenShape),
51 Layer.succeed(AnalyticsService, {
52 record: () => Effect.void,
53 flush: Effect.void,
54 }),
50 NodeServices.layer,55 NodeServices.layer,
51);56);
5257
apps/server/src/provider/Layers/ProviderService.test.ts+11
...@@ -42,6 +42,7 @@ import {...@@ -42,6 +42,7 @@ import {
42 makeSqlitePersistenceLive,42 makeSqlitePersistenceLive,
43 SqlitePersistenceMemory,43 SqlitePersistenceMemory,
44} from "../../persistence/Layers/Sqlite.ts";44} from "../../persistence/Layers/Sqlite.ts";
45import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
4546
46const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);47const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
47const asEventId = (value: string): EventId => EventId.makeUnsafe(value);48const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
...@@ -214,6 +215,11 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {...@@ -214,6 +215,11 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
214const sleep = (ms: number) =>215const sleep = (ms: number) =>
215 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));216 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
216217
218const noopAnalyticsLayer = Layer.succeed(AnalyticsService, {
219 record: () => Effect.void,
220 flush: Effect.void,
221});
222
217function makeProviderServiceLayer() {223function makeProviderServiceLayer() {
218 const codex = makeFakeCodexAdapter();224 const codex = makeFakeCodexAdapter();
219 const registry: typeof ProviderAdapterRegistry.Service = {225 const registry: typeof ProviderAdapterRegistry.Service = {
...@@ -235,8 +241,10 @@ function makeProviderServiceLayer() {...@@ -235,8 +241,10 @@ function makeProviderServiceLayer() {
235 makeProviderServiceLive().pipe(241 makeProviderServiceLive().pipe(
236 Layer.provide(providerAdapterLayer),242 Layer.provide(providerAdapterLayer),
237 Layer.provide(directoryLayer),243 Layer.provide(directoryLayer),
244 Layer.provideMerge(noopAnalyticsLayer),
238 ),245 ),
239 directoryLayer,246 directoryLayer,
247
240 runtimeRepositoryLayer,248 runtimeRepositoryLayer,
241 NodeServices.layer,249 NodeServices.layer,
242 ),250 ),
...@@ -280,6 +288,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (...@@ -280,6 +288,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
280 const providerLayer = makeProviderServiceLive().pipe(288 const providerLayer = makeProviderServiceLive().pipe(
281 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),289 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),
282 Layer.provide(directoryLayer),290 Layer.provide(directoryLayer),
291 Layer.provide(noopAnalyticsLayer),
283 );292 );
284293
285 yield* Effect.gen(function* () {294 yield* Effect.gen(function* () {
...@@ -338,6 +347,7 @@ it.effect(...@@ -338,6 +347,7 @@ it.effect(
338 const firstProviderLayer = makeProviderServiceLive().pipe(347 const firstProviderLayer = makeProviderServiceLive().pipe(
339 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),348 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
340 Layer.provide(firstDirectoryLayer),349 Layer.provide(firstDirectoryLayer),
350 Layer.provide(noopAnalyticsLayer),
341 );351 );
342352
343 const startedSession = yield* Effect.gen(function* () {353 const startedSession = yield* Effect.gen(function* () {
...@@ -380,6 +390,7 @@ it.effect(...@@ -380,6 +390,7 @@ it.effect(
380 const secondProviderLayer = makeProviderServiceLive().pipe(390 const secondProviderLayer = makeProviderServiceLive().pipe(
381 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),391 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
382 Layer.provide(secondDirectoryLayer),392 Layer.provide(secondDirectoryLayer),
393 Layer.provide(noopAnalyticsLayer),
383 );394 );
384395
385 secondCodex.startSession.mockClear();396 secondCodex.startSession.mockClear();
apps/server/src/provider/Layers/ProviderService.ts+47
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31 type ProviderRuntimeBinding,31 type ProviderRuntimeBinding,
32} from "../Services/ProviderSessionDirectory.ts";32} from "../Services/ProviderSessionDirectory.ts";
33import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";33import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
34import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
3435
35export interface ProviderServiceLiveOptions {36export interface ProviderServiceLiveOptions {
36 readonly canonicalEventLogPath?: string;37 readonly canonicalEventLogPath?: string;
...@@ -108,6 +109,7 @@ function readPersistedCwd(...@@ -108,6 +109,7 @@ function readPersistedCwd(
108109
109const makeProviderService = (options?: ProviderServiceLiveOptions) =>110const makeProviderService = (options?: ProviderServiceLiveOptions) =>
110 Effect.gen(function* () {111 Effect.gen(function* () {
112 const analytics = yield* Effect.service(AnalyticsService);
111 const canonicalEventLogger =113 const canonicalEventLogger =
112 options?.canonicalEventLogger ??114 options?.canonicalEventLogger ??
113 (options?.canonicalEventLogPath !== undefined115 (options?.canonicalEventLogPath !== undefined
...@@ -178,6 +180,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -178,6 +180,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
178 const existing = activeSessions.find((session) => session.threadId === input.binding.threadId);180 const existing = activeSessions.find((session) => session.threadId === input.binding.threadId);
179 if (existing) {181 if (existing) {
180 yield* upsertSessionBinding(existing, input.binding.threadId);182 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 });
181 return { adapter, session: existing } as const;188 return { adapter, session: existing } as const;
182 }189 }
183 }190 }
...@@ -206,6 +213,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -206,6 +213,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
206 }213 }
207214
208 yield* upsertSessionBinding(resumed, input.binding.threadId);215 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 });
209 return { adapter, session: resumed } as const;221 return { adapter, session: resumed } as const;
210 });222 });
211223
...@@ -262,6 +274,13 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -262,6 +274,13 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
262 }274 }
263275
264 yield* upsertSessionBinding(session, threadId);276 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
266 return session;285 return session;
267 });286 });
...@@ -301,6 +320,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -301,6 +320,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
301 lastRuntimeEventAt: new Date().toISOString(),320 lastRuntimeEventAt: new Date().toISOString(),
302 },321 },
303 });322 });
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 });
304 return turn;328 return turn;
305 });329 });
306330
...@@ -317,6 +341,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -317,6 +341,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
317 allowRecovery: true,341 allowRecovery: true,
318 });342 });
319 yield* routed.adapter.interruptTurn(routed.threadId, input.turnId);343 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 });
320 });348 });
321349
322 const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) =>350 const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) =>
...@@ -332,6 +360,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -332,6 +360,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
332 allowRecovery: true,360 allowRecovery: true,
333 });361 });
334 yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision);362 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 });
335 });367 });
336368
337 const respondToUserInput: ProviderServiceShape["respondToUserInput"] = (rawInput) =>369 const respondToUserInput: ProviderServiceShape["respondToUserInput"] = (rawInput) =>
...@@ -347,6 +379,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -347,6 +379,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
347 allowRecovery: true,379 allowRecovery: true,
348 });380 });
349 yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers);381 yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers);
382 yield* analytics.record("provider.user_input.responded", {
383 provider: routed.adapter.provider,
384 });
350 });385 });
351386
352 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>387 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>
...@@ -365,6 +400,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -365,6 +400,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
365 yield* routed.adapter.stopSession(routed.threadId);400 yield* routed.adapter.stopSession(routed.threadId);
366 }401 }
367 yield* directory.remove(input.threadId);402 yield* directory.remove(input.threadId);
403 yield* analytics.record("provider.session.stopped", {
404 provider: routed.adapter.provider,
405 wasActive: routed.isActive,
406 });
368 });407 });
369408
370 const listSessions: ProviderServiceShape["listSessions"] = () =>409 const listSessions: ProviderServiceShape["listSessions"] = () =>
...@@ -429,6 +468,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -429,6 +468,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
429 allowRecovery: true,468 allowRecovery: true,
430 });469 });
431 yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns);470 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 });
432 });475 });
433476
434 const stopAll: ProviderServiceShape["stopAll"] = () =>477 const stopAll: ProviderServiceShape["stopAll"] = () =>
...@@ -451,6 +494,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -451,6 +494,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
451 ),494 ),
452 ),495 ),
453 ).pipe(Effect.asVoid);496 ).pipe(Effect.asVoid);
497 yield* analytics.record("provider.sessions.stopped_all", {
498 sessionCount: threadIds.length,
499 });
500 yield* analytics.flush;
454 });501 });
455502
456 return {503 return {
apps/server/src/serverLayers.ts+2-1
...@@ -34,11 +34,12 @@ import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration";...@@ -34,11 +34,12 @@ import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration";
34import { GitServiceLive } from "./git/Layers/GitService";34import { GitServiceLive } from "./git/Layers/GitService";
35import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY";35import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY";
36import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY";36import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY";
37import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
3738
38export function makeServerProviderLayer(): Layer.Layer<39export function makeServerProviderLayer(): Layer.Layer<
39 ProviderService,40 ProviderService,
40 ProviderUnsupportedError,41 ProviderUnsupportedError,
41 SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem42 SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService
42> {43> {
43 return Effect.gen(function* () {44 return Effect.gen(function* () {
44 const { stateDir } = yield* ServerConfig;45 const { stateDir } = yield* ServerConfig;
apps/server/src/telemetry/Identify.ts created+77
...@@ -0,0 +1,77 @@
1import { Effect, FileSystem, Path, Random, Schema } from "effect";
2import * as Crypto from "node:crypto";
3import { homedir } from "node:os";
4
5const CodexAuthJsonSchema = Schema.Struct({
6 tokens: Schema.Struct({
7 account_id: Schema.String,
8 }),
9});
10
11class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", {
12 message: Schema.String,
13 cause: Schema.optional(Schema.Defect),
14}) {}
15
16const 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
26const 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
39const 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 */
62export 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 created+137
...@@ -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
10import { Config, DateTime, Effect, Layer, Ref } from "effect";
11import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
12
13import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts";
14import { getTelemetryIdentifier } from "../Identify.ts";
15
16interface BufferedAnalyticsEvent {
17 readonly event: string;
18 readonly properties?: Readonly<Record<string, unknown>>;
19 readonly capturedAt: string;
20}
21
22const 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
36const 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
137export const AnalyticsServiceLayerLive = Layer.effect(AnalyticsService, makeAnalyticsService);
apps/server/src/telemetry/Services/AnalyticsService.ts created+28
...@@ -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 */
9import { Effect, ServiceMap } from "effect";
10
11export 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
26export 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";...@@ -50,6 +50,8 @@ import type { GitCoreShape } from "./git/Services/GitCore.ts";
50import { GitCore } from "./git/Services/GitCore.ts";50import { GitCore } from "./git/Services/GitCore.ts";
51import { GitCommandError, GitManagerError } from "./git/Errors.ts";51import { GitCommandError, GitManagerError } from "./git/Errors.ts";
52import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";52import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
53import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts";
54import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
5355
54interface PendingMessages {56interface PendingMessages {
55 queue: unknown[];57 queue: unknown[];
...@@ -393,10 +395,7 @@ describe("WebSocket Server", () => {...@@ -393,10 +395,7 @@ describe("WebSocket Server", () => {
393 providerHealth?: ProviderHealthShape;395 providerHealth?: ProviderHealthShape;
394 open?: OpenShape;396 open?: OpenShape;
395 gitManager?: GitManagerShape;397 gitManager?: GitManagerShape;
396 gitCore?: Pick<398 gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">;
397 GitCoreShape,
398 "listBranches" | "initRepo" | "pullCurrentBranch"
399 >;
400 terminalManager?: TerminalManagerShape;399 terminalManager?: TerminalManagerShape;
401 } = {},400 } = {},
402 ): Promise<Http.Server> {401 ): Promise<Http.Server> {
...@@ -449,7 +448,9 @@ describe("WebSocket Server", () => {...@@ -449,7 +448,9 @@ describe("WebSocket Server", () => {
449 Layer.provideMerge(providerHealthLayer),448 Layer.provideMerge(providerHealthLayer),
450 Layer.provideMerge(openLayer),449 Layer.provideMerge(openLayer),
451 Layer.provideMerge(serverConfigLayer),450 Layer.provideMerge(serverConfigLayer),
451 Layer.provideMerge(AnalyticsServiceLayerLive),
452 Layer.provideMerge(NodeServices.layer),452 Layer.provideMerge(NodeServices.layer),
453 Layer.provideMerge(NodeHttpClient.layerUndici),
453 );454 );
454 const runtimeServices = await Effect.runPromise(455 const runtimeServices = await Effect.runPromise(
455 Layer.build(dependenciesLayer).pipe(Scope.provide(scope)),456 Layer.build(dependenciesLayer).pipe(Scope.provide(scope)),
...@@ -1620,7 +1621,6 @@ describe("WebSocket Server", () => {...@@ -1620,7 +1621,6 @@ describe("WebSocket Server", () => {
1620 expect(pullResponse.result).toBeUndefined();1621 expect(pullResponse.result).toBeUndefined();
1621 expect(pullResponse.error?.message).toContain("No upstream configured");1622 expect(pullResponse.error?.message).toContain("No upstream configured");
1622 expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");1623 expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");
1623
1624 });1624 });
16251625
1626 it("supports git.status over websocket", async () => {1626 it("supports git.status over websocket", async () => {
apps/server/src/wsServer.ts+3-1
...@@ -71,6 +71,7 @@ import {...@@ -71,6 +71,7 @@ import {
71 resolveAttachmentPathById,71 resolveAttachmentPathById,
72} from "./attachmentStore.ts";72} from "./attachmentStore.ts";
73import { parseBase64DataUrl } from "./imageMime.ts";73import { parseBase64DataUrl } from "./imageMime.ts";
74import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
7475
75/**76/**
76 * ServerShape - Service API for server lifecycle control.77 * ServerShape - Service API for server lifecycle control.
...@@ -207,7 +208,8 @@ export type ServerRuntimeServices =...@@ -207,7 +208,8 @@ export type ServerRuntimeServices =
207 | GitCore208 | GitCore
208 | TerminalManager209 | TerminalManager
209 | Keybindings210 | Keybindings
210 | Open;211 | Open
212 | AnalyticsService;
211213
212export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(214export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(
213 "ServerLifecycleError",215 "ServerLifecycleError",