154 lines · 5.1 KB · TypeScript
RawDownload
/** * 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<Record<string, unknown>>;  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<ReadonlyArray<BufferedAnalyticsEvent>>([]);  const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client";   const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) =>    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<BufferedAnalyticsEvent>,  ) {    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<BufferedAnalyticsEvent>, 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);