Source: utils/telemetry.js

import crypto from "crypto";
import os from "os";
import fs from "fs";
import path from "path";
import { formatLogLine } from "./logFormatter.js";

const TELEMETRY_ENABLED = process.env.SENTRI_TELEMETRY !== "0" && process.env.DO_NOT_TRACK !== "1";
const POSTHOG_KEY = process.env.POSTHOG_API_KEY || "";
const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
const CACHE_DIR = path.join(process.cwd(), "data");
const CACHE_FILE = path.join(CACHE_DIR, "telemetry-cache.json");

// Events that are sent at most once per install lifetime (e.g. first run).
// All other events are sent every time — daily-dedup would under-count usage.
const ONCE_PER_INSTALL = new Set(["install.first_run"]);

let PostHog = null;
let posthogLoadAttempted = false;
let client = null;

// In-memory mirror of the persisted "once per install" cache. Avoids a sync
// disk read on every event in the request hot path. The file is only touched
// on first event of a given lifetime key.
let cacheMem = null;

async function loadPostHog() {
  if (posthogLoadAttempted) return;
  posthogLoadAttempted = true;
  try { ({ PostHog } = await import("posthog-node")); } catch { /* optional dep */ }
}

function anonId() {
  const raw = `${os.hostname()}|${process.cwd()}`;
  return crypto.createHash("sha256").update(raw).digest("hex");
}

function loadCache() {
  if (cacheMem) return cacheMem;
  try { cacheMem = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); }
  catch { cacheMem = {}; }
  return cacheMem;
}

function saveCacheAsync(cache) {
  fs.promises.mkdir(CACHE_DIR, { recursive: true })
    .then(() => fs.promises.writeFile(CACHE_FILE, JSON.stringify(cache)))
    .catch(() => { /* best-effort */ });
}

/**
 * Lifetime dedup for events in ONCE_PER_INSTALL. Returns true if the event
 * should be sent. All other events are always sent.
 */
function shouldSend(eventName) {
  if (!ONCE_PER_INSTALL.has(eventName)) return true;
  const cache = loadCache();
  // Prune any legacy daily keys (`YYYY-MM-DD:event`) left over from the old
  // dedup scheme to keep the file bounded.
  let pruned = false;
  for (const k of Object.keys(cache)) {
    if (/^\d{4}-\d{2}-\d{2}:/.test(k)) { delete cache[k]; pruned = true; }
  }
  if (cache[eventName]) {
    if (pruned) saveCacheAsync(cache);
    return false;
  }
  cache[eventName] = true;
  saveCacheAsync(cache);
  return true;
}

function sanitizeProps(props = {}) {
  const next = { ...props };
  if (next.url) {
    try { next.domain = new URL(next.url).hostname; } catch {}
    delete next.url;
  }
  return next;
}

export function trackTelemetry(eventName, props = {}) {
  if (!TELEMETRY_ENABLED || !POSTHOG_KEY) return;
  if (!shouldSend(eventName)) return;
  // Fire-and-forget: never block or throw on the request hot path.
  (async () => {
    try {
      await loadPostHog();
      if (!PostHog) return;
      if (!client) client = new PostHog(POSTHOG_KEY, { host: POSTHOG_HOST, flushAt: 1, flushInterval: 0 });
      // posthog-node v4 `capture()` is synchronous fire-and-forget (returns void).
      client.capture({ distinctId: anonId(), event: eventName, properties: sanitizeProps(props) });
    } catch {
      console.warn(formatLogLine("warn", null, `[telemetry] failed to capture ${eventName}`));
    }
  })();
}