/** * AnalyticsServiceLive - Anonymous PostHog telemetry layer. * * Persists a random installation-scoped anonymous id to state dir, buffers * events in memory, and flushes batches to PostHog over Effect HttpClient. * * @module AnalyticsServiceLive */ import { Config, DateTime, Effect, Layer, Ref } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../../config.ts"; import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; import { getTelemetryIdentifier } from "../Identify.ts"; import packageJson from "../../../package.json" with { type: "json" }; interface BufferedAnalyticsEvent { readonly event: string; readonly properties?: Readonly>; readonly capturedAt: string; } const TelemetryEnvConfig = Config.all({ posthogKey: Config.string("T3CODE_POSTHOG_KEY").pipe( Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"), ), posthogHost: Config.string("T3CODE_POSTHOG_HOST").pipe( Config.withDefault("https://us.i.posthog.com"), ), enabled: Config.boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)), flushBatchSize: Config.number("T3CODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)), maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe( Config.withDefault(1_000), ), }); const makeAnalyticsService = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig.asEffect(); const httpClient = yield* HttpClient.HttpClient; const serverConfig = yield* ServerConfig; const identifier = yield* getTelemetryIdentifier; const bufferRef = yield* Ref.make>([]); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; const enqueueBufferedEvent = (event: string, properties?: Readonly>) => Effect.flatMap(DateTime.now, (now) => Ref.modify(bufferRef, (current) => { const appended = [ ...current, { event, ...(properties ? { properties } : {}), capturedAt: DateTime.formatIso(now), } satisfies BufferedAnalyticsEvent, ]; const next = appended.length > telemetryConfig.maxBufferedEvents ? appended.slice(appended.length - telemetryConfig.maxBufferedEvents) : appended; return [ { size: next.length, dropped: next.length !== appended.length, } as const, next, ] as const; }), ); const sendBatch = Effect.fn("sendBatch")(function* ( events: ReadonlyArray, ) { if (!telemetryConfig.enabled || !identifier) return; const payload = { api_key: telemetryConfig.posthogKey, batch: events.map((event) => ({ event: event.event, distinct_id: identifier, properties: { ...event.properties, $process_person_profile: false, platform: process.platform, wsl: process.env.WSL_DISTRO_NAME, arch: process.arch, t3CodeVersion: packageJson.version, clientType, }, timestamp: event.capturedAt, })), }; yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe( HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.flatMap(HttpClientResponse.filterStatusOk), ); }); const flush: AnalyticsServiceShape["flush"] = Effect.gen(function* () { while (true) { const batch = yield* Ref.modify(bufferRef, (current) => { if (current.length === 0) { return [[] as ReadonlyArray, current] as const; } const nextBatch = current.slice(0, telemetryConfig.flushBatchSize); const remaining = current.slice(nextBatch.length); return [nextBatch, remaining] as const; }); if (batch.length === 0) { return; } yield* sendBatch(batch).pipe( Effect.catch((error) => Ref.update(bufferRef, (current) => [...batch, ...current]).pipe( Effect.flatMap(() => Effect.fail(error)), ), ), ); } }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause }))); const record: AnalyticsServiceShape["record"] = Effect.fn("record")( function* (event, properties) { if (!telemetryConfig.enabled || !identifier) return; const enqueueResult = yield* enqueueBufferedEvent(event, properties); if (enqueueResult.dropped) { yield* Effect.logDebug("analytics buffer full; dropping oldest event", { size: enqueueResult.size, event, }); } }, ); yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), { disableYield: true, }).pipe(Effect.forkScoped); yield* Effect.addFinalizer(() => flush); return { record, flush, } satisfies AnalyticsServiceShape; }); export const AnalyticsServiceLayerLive = Layer.effect(AnalyticsService, makeAnalyticsService);