// --------------------------------------------------------------------------- // Anonymous product analytics for the non-cloud hosts (local daemon, self-host). // // Model (mirrors t3code's server telemetry): a per-install anonymous id is // minted once and persisted under the host's data dir; events buffer in memory // and flush to PostHog's /batch/ endpoint on a fixed cadence plus a finalizer // flush at shutdown. Delivery is best-effort — a failed flush re-queues the // batch and never surfaces to the caller; analytics can never fail or slow a // user-facing operation. // // Opt-out is the established cross-cutting convention: DO_NOT_TRACK (see // consoledonottrack.com, honored by the desktop diagnostics and the // integrations-registry fetch) or the feature-specific // EXECUTOR_DISABLE_ANALYTICS. Disabled -> the layer builds a no-op service, so // callers never branch. // --------------------------------------------------------------------------- import { Context, DateTime, Duration, Effect, Layer, Ref, Schedule } from "effect"; import { FileSystem } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse, } from "effect/unstable/http"; import { NodeFileSystem } from "@effect/platform-node"; import type { AnalyticsEventName, AnalyticsEvents } from "./events"; // --------------------------------------------------------------------------- // Surface vocabulary // --------------------------------------------------------------------------- /** * Which product hosts the daemon emitting the event. Always explicit at the * composition root — there is deliberately no "local" catch-all and no env * fallback inside this package; a host that cannot say which product it is * has a wiring bug worth surfacing, not papering over. */ export type AnalyticsSurface = "cli" | "desktop" | "selfhost"; // --------------------------------------------------------------------------- // Disable hooks // --------------------------------------------------------------------------- const truthy = (value: string | undefined): boolean => { if (value === undefined) return false; const normalized = value.trim().toLowerCase(); return normalized === "1" || normalized === "true" || normalized === "yes"; }; export const isAnalyticsDisabled = ( env: Readonly> = process.env, ): boolean => truthy(env.DO_NOT_TRACK) || truthy(env.EXECUTOR_DISABLE_ANALYTICS); // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- export interface AnalyticsService { /** Buffer an event for best-effort batched delivery. Never fails. */ readonly record: ( name: Name, properties: AnalyticsEvents[Name], ) => Effect.Effect; /** Drain the buffer now (shutdown path; the layer also flushes on a cadence). */ readonly flush: Effect.Effect; } export class Analytics extends Context.Service()( "@executor-js/analytics/Analytics", ) {} /** No-op service: what the layer builds when analytics is disabled. */ export const analyticsNoop: AnalyticsService = { record: () => Effect.void, flush: Effect.void, }; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- // The same PostHog project the cloud browser client reports to (the key is // public by design — it identifies the project, it grants no read access). const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; const DEFAULT_POSTHOG_KEY = "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL"; export interface AnalyticsConfig { /** Which product hosts this daemon. Explicit, decided at the composition root. */ readonly surface: AnalyticsSurface; /** Product version (the hosting app's package version). */ readonly version: string; /** Release channel, matching the installation user-agent vocabulary. */ readonly channel: "stable" | "beta" | "dev"; /** * Directory the anonymous install id persists under (the host's data dir: * `~/.executor` for local, the self-host data dir for self-host). The id * file is `/analytics-id`. */ readonly dataDir: string; /** Disable entirely. Honors DO_NOT_TRACK and EXECUTOR_DISABLE_ANALYTICS if omitted. */ readonly disabled?: boolean; /** Override the PostHog ingest origin (tests). */ readonly posthogHost?: string; /** Override the PostHog project key (tests). */ readonly posthogKey?: string; /** Override the flush cadence. Defaults to 30 seconds. */ readonly flushInterval?: Duration.Input; } const FLUSH_BATCH_SIZE = 50; const MAX_BUFFERED_EVENTS = 500; // --------------------------------------------------------------------------- // Anonymous install id // --------------------------------------------------------------------------- const ANALYTICS_ID_FILE = "analytics-id"; // Minted once per install, persisted forever (mirrors self-host's // generate-and-persist secret.key shape, minus the secrecy — this id is not a // credential, it is the anonymous distinct_id the events file under). const loadOrMintAnonymousId = ( dataDir: string, ): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const idPath = `${dataDir}/${ANALYTICS_ID_FILE}`; const existing = yield* fs.readFileString(idPath).pipe( Effect.map((raw) => raw.trim()), Effect.catch(() => Effect.succeed("")), ); if (existing.length > 0) return existing; const minted = crypto.randomUUID(); yield* fs.makeDirectory(dataDir, { recursive: true }).pipe(Effect.ignore); yield* fs.writeFileString(idPath, minted).pipe(Effect.ignore); return minted; }); // --------------------------------------------------------------------------- // Layer // --------------------------------------------------------------------------- interface BufferedEvent { readonly event: AnalyticsEventName; readonly properties: Record; readonly timestamp: string; } export const layer = ( config: AnalyticsConfig, ): Layer.Layer => Layer.effect(Analytics)( Effect.gen(function* () { const disabled = config.disabled ?? isAnalyticsDisabled(); if (disabled) return analyticsNoop; const http = yield* HttpClient.HttpClient; const distinctId = yield* loadOrMintAnonymousId(config.dataDir); const buffer = yield* Ref.make>([]); const posthogHost = config.posthogHost ?? DEFAULT_POSTHOG_HOST; const posthogKey = config.posthogKey ?? DEFAULT_POSTHOG_KEY; const sendBatch = (events: ReadonlyArray) => HttpClientRequest.post(`${posthogHost}/batch/`).pipe( HttpClientRequest.bodyJson({ api_key: posthogKey, batch: events.map((entry) => ({ event: entry.event, distinct_id: distinctId, timestamp: entry.timestamp, properties: { ...entry.properties, // Anonymous events only: never materialize a person profile. $process_person_profile: false, surface: config.surface, channel: config.channel, app_version: config.version, }, })), }), Effect.flatMap(http.execute), Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.asVoid, Effect.timeout(Duration.seconds(10)), ); const flush: Effect.Effect = Effect.gen(function* () { while (true) { const batch = yield* Ref.modify(buffer, (current) => { const next = current.slice(0, FLUSH_BATCH_SIZE); return [next, current.slice(next.length)] as const; }); if (batch.length === 0) return; yield* sendBatch(batch).pipe( // Delivery failed: put the batch back for the next cadence tick and // stop draining (the endpoint is unreachable, later batches would // fail the same way). Effect.tapError(() => Ref.update(buffer, (current) => [...batch, ...current])), ); } }).pipe( Effect.catchCause((cause) => Effect.logDebug("Analytics.flush failed").pipe(Effect.annotateLogs("cause", cause)), ), ); const record: AnalyticsService["record"] = (name, properties) => Effect.flatMap(DateTime.now, (now) => Ref.update(buffer, (current) => { const appended = [ ...current, { event: name, properties: properties as Record, timestamp: DateTime.formatIso(now), }, ]; // Bounded buffer: a dead network must not grow memory forever. return appended.length > MAX_BUFFERED_EVENTS ? appended.slice(appended.length - MAX_BUFFERED_EVENTS) : appended; }), ); const flushEvery = config.flushInterval ?? Duration.seconds(30); yield* Effect.forkScoped( flush.pipe(Effect.schedule(Schedule.spaced(flushEvery)), Effect.ignore), ); yield* Effect.addFinalizer(() => flush); return { record, flush }; }), ); /** The layer over the default platform services (real HTTP + disk). */ export const defaultLayer = (config: AnalyticsConfig): Layer.Layer => layer(config).pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer));