Repository: ponder-sh/ponder
Pull request #798 (2024-04-13 23:22:59)

Improve telemetry


Patch:
diff --git a/docs/package.json b/docs/package.json
index b2bc2dfd..3d6733b2 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -16,6 +16,7 @@
     "next": "^14.0.2",
     "nextra": "3.0.0-alpha.10",
     "nextra-theme-docs": "3.0.0-alpha.10",
+    "posthog-node": "^4.0.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "tailwind-merge": "^2.1.0",
diff --git a/docs/pages/api/telemetry/index.ts b/docs/pages/api/telemetry/index.ts
index 5b55a652..fc48f05f 100644
--- a/docs/pages/api/telemetry/index.ts
+++ b/docs/pages/api/telemetry/index.ts
@@ -1,10 +1,13 @@
-import type { TrackParams } from "@segment/analytics-node";
-import { Analytics } from "@segment/analytics-node";
+import { Analytics, type TrackParams } from "@segment/analytics-node";
 import type { NextApiRequest, NextApiResponse } from "next";
+import { PostHog } from "posthog-node";
 
-if (!process.env.SEGMENT_WRITE_KEY) {
+if (!process.env.SEGMENT_WRITE_KEY)
   throw new Error('Missing required environment variable "SEGMENT_WRITE_KEY".');
-}
+if (!process.env.POSTHOG_PROJECT_API_KEY)
+  throw new Error(
+    'Missing required environment variable "POSTHOG_PROJECT_API_KEY".',
+  );
 
 const analytics = new Analytics({
   writeKey: process.env.SEGMENT_WRITE_KEY,
@@ -15,6 +18,12 @@ const analytics = new Analytics({
   maxEventsInBatch: 1,
 });
 
+const client = new PostHog(process.env.POSTHOG_PROJECT_API_KEY, {
+  host: "https://app.posthog.com",
+  flushAt: 1,
+  flushInterval: 0,
+});
+
 const asyncTrack = (payload: TrackParams) => {
   return new Promise<void>((resolve, reject) => {
     analytics.track(payload, (err) => {
@@ -34,14 +43,27 @@ export default async function forwardTelemetry(
     });
   }
 
-  try {
-    await asyncTrack(req.body);
-    res.status(200).json({ success: true });
-  } catch (e) {
-    console.error("Error processing telemetry data:", {
-      error: e,
-      body: req.body,
-    });
-    res.status(500).json({ error: "Server error" });
+  const body = req.body;
+
+  const handleError = (error: unknown) => {
+    console.error("Error processing telemetry data:", { error, body });
+    return res.status(500).json({ error: "Server error" });
+  };
+
+  // If the event has an distinctId, it's a new Posthog event from >= 0.4.3
+  if (body.distinctId) {
+    client.on("error", handleError);
+    client.capture(body);
+    await client.shutdown();
   }
+  // Otherwise, assume it's a Segment event
+  else {
+    try {
+      await asyncTrack(body);
+    } catch (error) {
+      handleError(error);
+    }
+  }
+
+  return res.status(200).json({ success: true });
 }
diff --git a/package.json b/package.json
index 36269fec..2891ce33 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,8 @@
   "packageManager": "pnpm@8.6.10",
   "pnpm": {
     "patchedDependencies": {
-      "graphql@16.8.1": "patches/graphql@16.8.1.patch"
+      "graphql@16.8.1": "patches/graphql@16.8.1.patch",
+      "detect-package-manager@3.0.1": "patches/detect-package-manager@3.0.1.patch"
     },
     "peerDependencyRules": {
       "ignoreMissing": ["node-fetch"]
diff --git a/packages/core/src/bin/commands/codegen.ts b/packages/core/src/bin/commands/codegen.ts
index 1fe1318b..2ae10177 100644
--- a/packages/core/src/bin/commands/codegen.ts
+++ b/packages/core/src/bin/commands/codegen.ts
@@ -3,7 +3,7 @@ import { runCodegen } from "@/common/codegen.js";
 import { LoggerService } from "@/common/logger.js";
 import { MetricsService } from "@/common/metrics.js";
 import { buildOptions } from "@/common/options.js";
-import { TelemetryService } from "@/common/telemetry.js";
+import { createTelemetry } from "@/common/telemetry.js";
 import type { CliOptions } from "../ponder.js";
 import { setupShutdown } from "../utils/shutdown.js";
 
@@ -25,7 +25,7 @@ export async function codegen({ cliOptions }: { cliOptions: CliOptions }) {
   }
 
   const metrics = new MetricsService();
-  const telemetry = new TelemetryService({ options });
+  const telemetry = createTelemetry({ options, logger });
   const common = { options, logger, metrics, telemetry };
 
   const buildService = await createBuildService({ common });
@@ -49,6 +49,11 @@ export async function codegen({ cliOptions }: { cliOptions: CliOptions }) {
     return;
   }
 
+  telemetry.record({
+    name: "lifecycle:session_start",
+    properties: { cli_command: "codegen" },
+  });
+
   runCodegen({ common, graphqlSchema: buildResult.build.graphqlSchema });
 
   logger.info({ service: "codegen", msg: "Wrote ponder-env.d.ts" });
diff --git a/packages/core/src/bin/commands/dev.ts b/packages/core/src/bin/commands/dev.ts
index 7da1a74a..38a1c6a9 100644
--- a/packages/core/src/bin/commands/dev.ts
+++ b/packages/core/src/bin/commands/dev.ts
@@ -4,7 +4,7 @@ import { type BuildResult, createBuildService } from "@/build/index.js";
 import { LoggerService } from "@/common/logger.js";
 import { MetricsService } from "@/common/metrics.js";
 import { buildOptions } from "@/common/options.js";
-import { TelemetryService } from "@/common/telemetry.js";
+import { buildPayload, createTelemetry } from "@/common/telemetry.js";
 import { UiService } from "@/ui/service.js";
 import { createQueue } from "@ponder/common";
 import type { CliOptions } from "../ponder.js";
@@ -42,7 +42,7 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) {
   });
 
   const metrics = new MetricsService();
-  const telemetry = new TelemetryService({ options });
+  const telemetry = createTelemetry({ options, logger });
   const common = { options, logger, metrics, telemetry };
 
   const buildService = await createBuildService({ common });
@@ -103,12 +103,8 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) {
   }
 
   telemetry.record({
-    event: "App Started",
-    properties: {
-      command: "ponder dev",
-      contractCount: initialResult.build.sources.length,
-      databaseKind: initialResult.build.databaseConfig.kind,
-    },
+    name: "lifecycle:session_start",
+    properties: { cli_command: "dev", ...buildPayload(initialResult.build) },
   });
 
   buildQueue.add(initialResult);
diff --git a/packages/core/src/bin/commands/serve.ts b/packages/core/src/bin/commands/serve.ts
index 177a56a6..5d92c332 100644
--- a/packages/core/src/bin/commands/serve.ts
+++ b/packages/core/src/bin/commands/serve.ts
@@ -3,7 +3,7 @@ import { createBuildService } from "@/build/index.js";
 import { LoggerService } from "@/common/logger.js";
 import { MetricsService } from "@/common/metrics.js";
 import { buildOptions } from "@/common/options.js";
-import { TelemetryService } from "@/common/telemetry.js";
+import { buildPayload, createTelemetry } from "@/common/telemetry.js";
 import { PostgresDatabaseService } from "@/database/postgres/service.js";
 import type { NamespaceInfo } from "@/database/service.js";
 import { RealtimeIndexingStore } from "@/indexing-store/realtimeStore.js";
@@ -35,7 +35,7 @@ export async function serve({ cliOptions }: { cliOptions: CliOptions }) {
   });
 
   const metrics = new MetricsService();
-  const telemetry = new TelemetryService({ options });
+  const telemetry = createTelemetry({ options, logger });
   const common = { options, logger, metrics, telemetry };
 
   const buildService = await createBuildService({ common });
@@ -59,12 +59,8 @@ export async function serve({ cliOptions }: { cliOptions: CliOptions }) {
   }
 
   telemetry.record({
-    event: "App Started",
-    properties: {
-      command: "ponder serve",
-      contractCount: initialResult.build.sources.length,
-      databaseKind: initialResult.build.databaseConfig.kind,
-    },
+    name: "lifecycle:session_start",
+    properties: { cli_command: "serve", ...buildPayload(initialResult.build) },
   });
 
   const { databaseConfig, schema, graphqlSchema } = initialResult.build;
diff --git a/packages/core/src/bin/commands/start.ts b/packages/core/src/bin/commands/start.ts
index 4e7fd7df..feb1eec2 100644
--- a/packages/core/src/bin/commands/start.ts
+++ b/packages/core/src/bin/commands/start.ts
@@ -3,7 +3,7 @@ import { createBuildService } from "@/build/index.js";
 import { LoggerService } from "@/common/logger.js";
 import { MetricsService } from "@/common/metrics.js";
 import { buildOptions } from "@/common/options.js";
-import { TelemetryService } from "@/common/telemetry.js";
+import { buildPayload, createTelemetry } from "@/common/telemetry.js";
 import type { CliOptions } from "../ponder.js";
 import { run } from "../utils/run.js";
 import { setupShutdown } from "../utils/shutdown.js";
@@ -32,7 +32,7 @@ export async function start({ cliOptions }: { cliOptions: CliOptions }) {
   });
 
   const metrics = new MetricsService();
-  const telemetry = new TelemetryService({ options });
+  const telemetry = createTelemetry({ options, logger });
   const common = { options, logger, metrics, telemetry };
 
   const buildService = await createBuildService({ common });
@@ -56,12 +56,8 @@ export async function start({ cliOptions }: { cliOptions: CliOptions }) {
   }
 
   telemetry.record({
-    event: "App Started",
-    properties: {
-      command: "ponder start",
-      contractCount: initialResult.build.sources.length,
-      databaseKind: initialResult.build.databaseConfig.kind,
-    },
+    name: "lifecycle:session_start",
+    properties: { cli_command: "start", ...buildPayload(initialResult.build) },
   });
 
   cleanupReloadable = await run({
diff --git a/packages/core/src/bin/utils/shutdown.ts b/packages/core/src/bin/utils/shutdown.ts
index 9e9a7209..767b01eb 100644
--- a/packages/core/src/bin/utils/shutdown.ts
+++ b/packages/core/src/bin/utils/shutdown.ts
@@ -35,8 +35,8 @@ export function setupShutdown({
       });
     }
     common.telemetry.record({
-      event: "App Killed",
-      properties: { processDuration: process.uptime() },
+      name: "lifecycle:session_end",
+      properties: { duration_seconds: process.uptime() },
     });
 
     await cleanup();
diff --git a/packages/core/src/common/common.ts b/packages/core/src/common/common.ts
index 004d0351..a3682fb9 100644
--- a/packages/core/src/common/common.ts
+++ b/packages/core/src/common/common.ts
@@ -1,11 +1,11 @@
 import type { LoggerService } from "./logger.js";
 import type { MetricsService } from "./metrics.js";
 import type { Options } from "./options.js";
-import type { TelemetryService } from "./telemetry.js";
+import type { Telemetry } from "./telemetry.js";
 
 export type Common = {
   options: Options;
   logger: LoggerService;
   metrics: MetricsService;
-  telemetry: TelemetryService;
+  telemetry: Telemetry;
 };
diff --git a/packages/core/src/common/options.ts b/packages/core/src/common/options.ts
index ccffafd7..684d956c 100644
--- a/packages/core/src/common/options.ts
+++ b/packages/core/src/common/options.ts
@@ -17,7 +17,7 @@ export type Options = {
 
   telemetryUrl: string;
   telemetryDisabled: boolean;
-  telemetryIsExampleProject: boolean;
+  telemetryConfigDir: string | undefined;
 
   logLevel: LevelWithSilent;
 };
@@ -78,9 +78,7 @@ export const buildOptions = ({ cliOptions }: { cliOptions: CliOptions }) => {
 
     telemetryUrl: "https://ponder.sh/api/telemetry",
     telemetryDisabled: Boolean(process.env.PONDER_TELEMETRY_DISABLED),
-    telemetryIsExampleProject: Boolean(
-      process.env.PONDER_TELEMETRY_IS_EXAMPLE_PROJECT,
-    ),
+    telemetryConfigDir: undefined,
 
     logLevel,
   } satisfies Options;
diff --git a/packages/core/src/common/telemetry.ts b/packages/core/src/common/telemetry.ts
index 94f24e0d..ff89f0ea 100644
--- a/packages/core/src/common/telemetry.ts
+++ b/packages/core/src/common/telemetry.ts
@@ -1,220 +1,279 @@
-import { createHash } from "node:crypto";
-import fs from "node:fs";
-
-import { randomBytes } from "crypto";
-import os from "os";
-import path from "path";
+import { exec } from "child_process";
+import { createHash, randomBytes } from "node:crypto";
+import { existsSync, readFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "util";
+import type { Build } from "@/build/service.js";
+import type { Options } from "@/common/options.js";
+import { createQueue } from "@ponder/common";
 import Conf from "conf";
-// @ts-ignore
-import { detect, getNpmVersion } from "detect-package-manager";
-import PQueue from "p-queue";
-import pc from "picocolors";
-import process from "process";
+import { type PM, detect, getNpmVersion } from "detect-package-manager";
+import type { LoggerService } from "./logger.js";
 
-import type { Options } from "@/common/options.js";
-import { getGitRemoteUrl } from "@/utils/git.js";
-import { wait } from "@/utils/wait.js";
+const HEARTBEAT_INTERVAL_MS = 60_000;
 
-type TelemetryEvent = {
-  event: string;
-  properties?: any;
-};
+type TelemetryEvent =
+  | {
+      name: "lifecycle:session_start";
+      properties: { cli_command: string };
+    }
+  | {
+      name: "lifecycle:session_end";
+      properties: { duration_seconds: number };
+    }
+  | {
+      name: "lifecycle:heartbeat_send";
+      properties: { duration_seconds: number };
+    };
 
-type TelemetryDeviceConfig = {
-  enabled: boolean;
-  notifiedAt: string;
-  anonymousId: string;
-  salt: string;
+type CommonProperties = {
+  // Identification
+  project_id: string;
+  session_id: string;
+  is_internal: boolean;
 };
 
-type TelemetryEventContext = {
-  projectId: string;
-  sessionId: string;
-  packageManager: string;
-  packageManagerVersion: string;
-  nodeVersion: string;
-  ponderVersion: string;
-  systemPlatform: NodeJS.Platform;
-  systemRelease: string;
-  systemArchitecture: string;
-  cpuCount: number;
-  cpuModel: string | null;
-  cpuSpeed: number | null;
-  memoryInMb: number;
-  isExampleProject: boolean;
+type SessionProperties = {
+  // Environment and package versions
+  package_manager: string;
+  package_manager_version: string;
+  node_version: string;
+  ponder_core_version: string;
+  viem_version: string;
+  // System and hardware
+  system_platform: NodeJS.Platform;
+  system_release: string;
+  system_architecture: string;
+  cpu_count: number;
+  cpu_model: string;
+  cpu_speed: number;
+  total_memory_bytes: number;
 };
 
-export class TelemetryService {
-  private options: Options;
-  private conf: Conf<TelemetryDeviceConfig>;
+type DeviceConf = {
+  notifiedAt?: string;
+  anonymousId?: string;
+  salt?: string;
+};
 
-  private queue = new PQueue({ concurrency: 1 });
-  private events: TelemetryEvent[] = [];
+export type Telemetry = ReturnType<typeof createTelemetry>;
+
+export function createTelemetry({
+  options,
+  logger,
+}: {
+  options: Options;
+  logger: LoggerService;
+}) {
+  if (options.telemetryDisabled) {
+    return { record: (_event: TelemetryEvent) => {}, kill: async () => {} };
+  }
 
-  private controller = new AbortController();
-  private context?: TelemetryEventContext;
-  private heartbeatIntervalId?: NodeJS.Timeout;
+  const conf = new Conf<DeviceConf>({
+    projectName: "ponder",
+    cwd: options.telemetryConfigDir,
+  });
 
-  constructor({ options }: { options: Options }) {
-    this.options = options;
-    this.conf = new Conf({ projectName: "ponder" });
-    this.notify();
-    this.heartbeatIntervalId = setInterval(() => {
-      this.record({ event: "Heartbeat" });
-    }, 60_000);
+  if (conf.get("notifiedAt") === undefined) {
+    conf.set("notifiedAt", Date.now().toString());
+    logger.info({
+      msg: "Ponder collects anonymous telemetry data to identify issues and prioritize features. See https://ponder.sh/advanced/telemetry for more information.",
+    });
   }
 
-  record(event: TelemetryEvent) {
-    if (this.disabled) return;
-    this.events.push(event);
-    this.queue.add(() => this.processEvent());
-  }
+  const sessionId = randomBytes(32).toString("hex");
 
-  async flush() {
-    await this.queue.onIdle();
+  let anonymousId = conf.get("anonymousId") as string;
+  if (anonymousId === undefined) {
+    anonymousId = randomBytes(32).toString("hex");
+    conf.set("anonymousId", anonymousId);
   }
 
-  private processEvent = async () => {
-    const event = this.events.pop();
-    if (!event) return;
+  let salt = conf.get("salt") as string;
+  if (salt === undefined) {
+    salt = randomBytes(32).toString("hex");
+    conf.set("salt", salt);
+  }
 
-    // Build the context. If it's already been built, this will return immediately.
-    try {
-      await this.getContext();
-    } catch (e) {
-      // Do nothing
-    }
+  // Prepend the value with a secret salt to ensure a credible one-way hash.
+  const oneWayHash = (value: string) => {
+    const hash = createHash("sha256");
+    hash.update(salt);
+    hash.update(value);
+    return hash.digest("hex");
+  };
 
-    // See https://segment.com/docs/connections/spec/track
-    const serializedEvent = {
-      ...event,
-      anonymousId: this.anonymousId,
-      context: this.context,
+  const buildContext = async () => {
+    // Project ID is a one-way hash of the git remote URL OR the current working directory.
+    const gitRemoteUrl = await getGitRemoteUrl();
+    const projectIdRaw = gitRemoteUrl ?? process.cwd();
+    const projectId = oneWayHash(projectIdRaw);
+
+    const { packageManager, packageManagerVersion } = await getPackageManager();
+
+    // Attempt to find and read the users package.json file.
+    const packageJson = getPackageJson(options.rootDir);
+    const ponderCoreVersion =
+      packageJson?.dependencies?.["@ponder/core"] ?? "unknown";
+    const viemVersion = packageJson?.dependencies?.viem ?? "unknown";
+
+    // Make a guess as to whether the project is internal (within the monorepo) or not.
+    const isInternal = ponderCoreVersion === "workspace:*";
+
+    const cpus = os.cpus();
+
+    return {
+      common: {
+        session_id: sessionId,
+        project_id: projectId,
+        is_internal: isInternal,
+      } satisfies CommonProperties,
+      session: {
+        ponder_core_version: ponderCoreVersion,
+        viem_version: viemVersion,
+        package_manager: packageManager,
+        package_manager_version: packageManagerVersion,
+        node_version: process.versions.node,
+        system_platform: os.platform(),
+        system_release: os.release(),
+        system_architecture: os.arch(),
+        cpu_count: cpus.length,
+        cpu_model: cpus.length > 0 ? cpus[0].model : "unknown",
+        cpu_speed: cpus.length > 0 ? cpus[0].speed : 0,
+        total_memory_bytes: os.totalmem(),
+      } satisfies SessionProperties,
     };
-
-    try {
-      await fetch(this.options.telemetryUrl, {
-        method: "POST",
-        body: JSON.stringify(serializedEvent),
-        headers: { "Content-Type": "application/json" },
-        signal: this.controller.signal,
-      });
-    } catch (e) {
-      // Do nothing
-    }
   };
 
-  async kill() {
-    clearInterval(this.heartbeatIntervalId);
-    this.queue.pause();
-    this.queue.clear();
-    await Promise.race([wait(500), this.queue.onIdle()]);
-    if (this.queue.pending > 0) {
-      this.controller.abort();
-    }
-  }
-
-  private notify() {
-    if (
-      this.disabled ||
-      this.conf.get("notifiedAt") ||
-      process.env.NODE_ENV === "test"
-    ) {
-      return;
+  let context: Awaited<ReturnType<typeof buildContext>> | undefined = undefined;
+  const contextPromise = buildContext();
+
+  let isKilled = false;
+
+  const queue = createQueue({
+    initialStart: true,
+    concurrency: 10,
+    worker: async (event: TelemetryEvent) => {
+      try {
+        if (context === undefined) context = await contextPromise;
+
+        const properties =
+          event.name === "lifecycle:session_start"
+            ? { ...event.properties, ...context.common, ...context.session }
+            : { ...event.properties, ...context.common };
+
+        const body = JSON.stringify({
+          distinctId: anonymousId,
+          event: event.name,
+          properties,
+        });
+
+        await fetch(options.telemetryUrl, {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body,
+          signal: AbortSignal.timeout(500),
+        });
+      } catch (error_) {
+        const error = error_ as Error;
+        logger.trace({
+          service: "telemetry",
+          msg: `Failed to send event due to error: ${error.message}`,
+        });
+      }
+    },
+  });
+
+  const record = (event: TelemetryEvent) => {
+    if (isKilled) {
+      throw new Error(
+        "Invariant violation, attempted to record event after telemetry service was killed",
+      );
     }
+    queue.add(event);
+  };
 
-    this.conf.set("notifiedAt", Date.now().toString());
-
-    console.log(
-      `${pc.magenta(
-        "Attention",
-      )}: Ponder collects anonymous telemetry data to identify issues and prioritize features. See https://ponder.sh/advanced/telemetry for more information.`,
-    );
-  }
+  const heartbeatInterval = setInterval(() => {
+    record({
+      name: "lifecycle:heartbeat_send",
+      properties: { duration_seconds: process.uptime() },
+    });
+  }, HEARTBEAT_INTERVAL_MS);
+
+  const kill = async () => {
+    clearInterval(heartbeatInterval);
+    isKilled = true;
+    // If there are any events in the queue that have not started, drop them.
+    queue.clear();
+    // Wait for any in-flight events to complete. This will take at most 500ms.
+    await queue.onIdle();
+  };
 
-  get disabled() {
-    return (
-      this.options.telemetryDisabled ||
-      (this.conf.has("enabled") && !this.conf.get("enabled"))
-    );
-  }
+  return { record, kill };
+}
 
-  private get anonymousId() {
-    const storedAnonymousId = this.conf.get("anonymousId");
-    if (storedAnonymousId) return storedAnonymousId;
+async function getPackageManager() {
+  let packageManager: PM = "unknown" as PM;
+  let packageManagerVersion = "unknown";
+  try {
+    packageManager = await detect();
+    packageManagerVersion = await getNpmVersion(packageManager);
+  } catch (e) {}
+  return { packageManager, packageManagerVersion };
+}
 
-    const createdId = randomBytes(32).toString("hex");
-    this.conf.set("anonymousId", createdId);
-    return createdId;
-  }
+const execa = promisify(exec);
 
-  private get salt() {
-    const storedSalt = this.conf.get("salt");
-    if (storedSalt) return storedSalt;
+async function getGitRemoteUrl() {
+  const result = await execa("git config --local --get remote.origin.url", {
+    timeout: 250,
+    windowsHide: true,
+  }).catch(() => undefined);
 
-    const createdSalt = randomBytes(32).toString("hex");
-    this.conf.set("salt", createdSalt);
-    return createdSalt;
-  }
+  return result?.stdout.trim();
+}
 
-  private oneWayHash(value: string) {
-    const hash = createHash("sha256");
-    // Always prepend the payload value with salt. This ensures the hash is truly
-    // one-way.
-    hash.update(this.salt);
-    hash.update(value);
-    return hash.digest("hex");
-  }
+type PackageJson = {
+  name?: string;
+  version?: string;
+  dependencies?: { [key: string]: string };
+  devDependencies?: { [key: string]: string };
+};
 
-  private async getContext() {
-    if (this.context) return this.context;
+function getPackageJson(rootDir: string) {
+  try {
+    const rootPath = path.join(rootDir, "package.json");
+    const cwdPath = path.join(process.cwd(), "package.json");
 
-    const sessionId = randomBytes(32).toString("hex");
-    const projectIdRaw = (await getGitRemoteUrl()) ?? process.cwd();
-    const projectId = this.oneWayHash(projectIdRaw);
+    const packageJsonPath = existsSync(rootPath)
+      ? rootPath
+      : existsSync(cwdPath)
+        ? cwdPath
+        : undefined;
+    if (packageJsonPath === undefined) return undefined;
 
-    let packageManager: any = "unknown";
-    let packageManagerVersion: any = "unknown";
-    try {
-      packageManager = await detect();
-      packageManagerVersion = await getNpmVersion(packageManager);
-    } catch (e) {
-      // Ignore
-    }
+    const packageJsonString = readFileSync(packageJsonPath, "utf8");
+    const packageJson = JSON.parse(packageJsonString) as PackageJson;
 
-    const packageJsonCwdPath = path.join(process.cwd(), "package.json");
-    const packageJsonRootPath = path.join(this.options.rootDir, "package.json");
-    const packageJsonPath = fs.existsSync(packageJsonCwdPath)
-      ? packageJsonCwdPath
-      : fs.existsSync(packageJsonRootPath)
-        ? packageJsonRootPath
-        : undefined;
-    const packageJson = packageJsonPath
-      ? JSON.parse(fs.readFileSync("package.json", "utf8"))
-      : undefined;
-    const ponderVersion = packageJson
-      ? packageJson.dependencies["@ponder/core"]
-      : "unknown";
-
-    const cpus = os.cpus() || [];
-
-    this.context = {
-      sessionId,
-      projectId,
-      nodeVersion: process.version,
-      packageManager,
-      packageManagerVersion,
-      ponderVersion,
-      systemPlatform: os.platform(),
-      systemRelease: os.release(),
-      systemArchitecture: os.arch(),
-      cpuCount: cpus.length,
-      cpuModel: cpus.length ? cpus[0].model : null,
-      cpuSpeed: cpus.length ? cpus[0].speed : null,
-      memoryInMb: Math.trunc(os.totalmem() / 1024 ** 2),
-      isExampleProject: this.options.telemetryIsExampleProject,
-    } satisfies TelemetryEventContext;
-
-    return this.context;
+    return packageJson;
+  } catch (e) {
+    return undefined;
   }
 }
+
+export function buildPayload(build: Build) {
+  const table_count = Object.keys(build.schema.tables).length;
+  const indexing_function_count = Object.values(build.indexingFunctions).reduce(
+    (acc, f) => acc + Object.keys(f).length,
+    0,
+  );
+
+  return {
+    database_kind: build.databaseConfig.kind,
+    contract_count: build.sources.length,
+    network_count: build.networks.length,
+    table_count,
+    indexing_function_count,
+  };
+}
diff --git a/packages/core/src/utils/git.ts b/packages/core/src/utils/git.ts
deleted file mode 100644
index a121f00d..00000000
--- a/packages/core/src/utils/git.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { exec } from "child_process";
-
-export async function getGitRemoteUrl() {
-  try {
-    let resolve: (value: Buffer | string) => void;
-    let reject: (err: Error) => void;
-    const promise = new Promise<Buffer | string>((res, rej) => {
-      resolve = res;
-      reject = rej;
-    });
-
-    exec(
-      "git config --local --get remote.origin.url",
-      {
-        timeout: 1000,
-        windowsHide: true,
-      },
-      (error: null | Error, stdout: Buffer | string) => {
-        if (error) {
-          reject(error);
-          return;
-        }
-        resolve(stdout);
-      },
-    );
-
-    return String(await promise).trim();
-  } catch (_) {
-    return null;
-  }
-}
diff --git a/packages/core/src/utils/withTablePrefixPlugin.ts b/packages/core/src/utils/withTablePrefixPlugin.ts
deleted file mode 100644
index 435bde49..00000000
--- a/packages/core/src/utils/withTablePrefixPlugin.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import type {
-  KyselyPlugin,
-  PluginTransformQueryArgs,
-  PluginTransformResultArgs,
-  QueryResult,
-  UnknownRow,
-} from "kysely";
-import {
-  OperationNodeTransformer,
-  type RootOperationNode,
-  TableNode,
-} from "kysely";
-
-// Custom transformer that appends "_raw_" prefix to table names
-class WithTablePrefixTransformer extends OperationNodeTransformer {
-  readonly #prefix: string;
-
-  constructor(prefix: string) {
-    super();
-    this.#prefix = prefix;
-  }
-
-  protected override transformTable(node: TableNode): TableNode {
-    if (node.table && !node.table.identifier.name.startsWith(this.#prefix)) {
-      return TableNode.create(`${this.#prefix}${node.table.identifier.name}`);
-    }
-    return node;
-  }
-}
-
-// Plugin class using the WithTablePrefixTransformer
-export class WithTablePrefixPlugin implements KyselyPlugin {
-  readonly #transformer: WithTablePrefixTransformer;
-
-  constructor(prefix: string) {
-    this.#transformer = new WithTablePrefixTransformer(prefix);
-  }
-
-  transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
-    // Use the transformer to modify the query's operation node
-    return this.#transformer.transformNode(args.node);
-  }
-
-  transformResult(
-    args: PluginTransformResultArgs,
-  ): Promise<QueryResult<UnknownRow>> {
-    // No modification to the result in this plugin
-    return Promise.resolve(args.result);
-  }
-}
diff --git a/patches/detect-package-manager@3.0.1.patch b/patches/detect-package-manager@3.0.1.patch
new file mode 100644
index 00000000..ba0e4f3c
--- /dev/null
+++ b/patches/detect-package-manager@3.0.1.patch
@@ -0,0 +1,12 @@
+diff --git a/package.json b/package.json
+index 588df1f4155e69fbdba65e6f8ceaf2ea91d67416..52930f98feb287cd7d0f576f32d670bbb8610394 100644
+--- a/package.json
++++ b/package.json
+@@ -10,6 +10,7 @@
+   "module": "dist/index.mjs",
+   "exports": {
+     ".": {
++      "types": "./dist/index.d.ts",
+       "require": "./dist/index.js",
+       "default": "./dist/index.mjs"
+     }
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 321ad347..011bf5e2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5,6 +5,9 @@ settings:
   excludeLinksFromLockfile: false
 
 patchedDependencies:
+  detect-package-manager@3.0.1:
+    hash: tkhlb7gk5ij4zxchvtwu3teirq
+    path: patches/detect-package-manager@3.0.1.patch
   graphql@16.8.1:
     hash: 3zvcnrptpojleshpmtp6be677a
     path: patches/graphql@16.8.1.patch
@@ -88,6 +91,9 @@ importers:
       nextra-theme-docs:
         specifier: 3.0.0-alpha.10
         version: 3.0.0-alpha.10(next@14.0.3)(nextra@3.0.0-alpha.10)(react-dom@18.2.0)(react@18.2.0)
+      posthog-node:
+        specifier: ^4.0.0
+        version: 4.0.0
       react:
         specifier: ^18.2.0
         version: 18.2.0
@@ -539,7 +545,7 @@ importers:
         version: 2.2.2
       detect-package-manager:
         specifier: ^3.0.1
-        version: 3.0.1
+        version: 3.0.1(patch_hash=tkhlb7gk5ij4zxchvtwu3teirq)
       dotenv:
         specifier: ^16.3.1
         version: 16.3.1
@@ -4565,6 +4571,9 @@ packages:
 
   /ajv-formats@2.1.1:
     resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
+    peerDependenciesMeta:
+      ajv:
+        optional: true
     dependencies:
       ajv: 8.12.0
     dev: false
@@ -4865,7 +4874,6 @@ packages:
 
   /asynckit@0.4.0:
     resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
-    dev: true
 
   /at-least-node@1.0.0:
     resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
@@ -4931,6 +4939,16 @@ packages:
       - debug
     dev: true
 
+  /axios@1.6.8:
+    resolution: {integrity: sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==}
+    dependencies:
+      follow-redirects: 1.15.6
+      form-data: 4.0.0
+      proxy-from-env: 1.1.0
+    transitivePeerDependencies:
+      - debug
+    dev: false
+
   /axobject-query@3.2.1:
     resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==}
     dependencies:
@@ -5609,7 +5627,6 @@ packages:
     engines: {node: '>= 0.8'}
     dependencies:
       delayed-stream: 1.0.0
-    dev: true
 
   /comma-separated-tokens@2.0.3:
     resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -6340,7 +6357,6 @@ packages:
   /delayed-stream@1.0.0:
     resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
     engines: {node: '>=0.4.0'}
-    dev: true
 
   /dequal@2.0.3:
     resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
@@ -6363,12 +6379,13 @@ packages:
       execa: 5.1.1
     dev: true
 
-  /detect-package-manager@3.0.1:
+  /detect-package-manager@3.0.1(patch_hash=tkhlb7gk5ij4zxchvtwu3teirq):
     resolution: {integrity: sha512-qoHDH6+lMcpJPAScE7+5CYj91W0mxZNXTwZPrCqi1KMk+x+AoQScQ2V1QyqTln1rHU5Haq5fikvOGHv+leKD8A==}
     engines: {node: '>=12'}
     dependencies:
       execa: 5.1.1
     dev: false
+    patched: true
 
   /devlop@1.1.0:
     resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@@ -7527,6 +7544,16 @@ packages:
       debug: 4.3.4(supports-color@8.1.1)
     dev: true
 
+  /follow-redirects@1.15.6:
+    resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
+    engines: {node: '>=4.0'}
+    peerDependencies:
+      debug: '*'
+    peerDependenciesMeta:
+      debug:
+        optional: true
+    dev: false
+
   /for-each@0.3.3:
     resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
     dependencies:
@@ -7562,6 +7589,15 @@ packages:
       mime-types: 2.1.35
     dev: true
 
+  /form-data@4.0.0:
+    resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
+    engines: {node: '>= 6'}
+    dependencies:
+      asynckit: 0.4.0
+      combined-stream: 1.0.8
+      mime-types: 2.1.35
+    dev: false
+
   /format@0.2.2:
     resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
     engines: {node: '>=0.4.x'}
@@ -10466,14 +10502,12 @@ packages:
   /mime-db@1.52.0:
     resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
     engines: {node: '>= 0.6'}
-    dev: true
 
   /mime-types@2.1.35:
     resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
     engines: {node: '>= 0.6'}
     dependencies:
       mime-db: 1.52.0
-    dev: true
 
   /mimic-fn@2.1.0:
     resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
@@ -11660,6 +11694,16 @@ packages:
     resolution: {integrity: sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==}
     dev: true
 
+  /posthog-node@4.0.0:
+    resolution: {integrity: sha512-jEZnNbgb/3FNk+gNwtTcyz3j+62zIN+UTPotONfacVXJnoI70KScSkKdIR+rvP9tA2kjBSoHQxGwJuizs27o9A==}
+    engines: {node: '>=15.0.0'}
+    dependencies:
+      axios: 1.6.8
+      rusha: 0.8.14
+    transitivePeerDependencies:
+      - debug
+    dev: false
+
   /prebuild-install@7.1.1:
     resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==}
     engines: {node: '>=10'}
@@ -11788,6 +11832,10 @@ packages:
       long: 4.0.0
     dev: true
 
+  /proxy-from-env@1.1.0:
+    resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+    dev: false
+
   /pseudomap@1.0.2:
     resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==}
 
@@ -12370,6 +12418,10 @@ packages:
     dependencies:
       queue-microtask: 1.2.3
 
+  /rusha@0.8.14:
+    resolution: {integrity: sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==}
+    dev: false
+
   /rw@1.3.3:
     resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
     dev: false


Test patch:
diff --git a/packages/core/src/_test/setup.ts b/packages/core/src/_test/setup.ts
index 7f0567b2..d833cd6b 100644
--- a/packages/core/src/_test/setup.ts
+++ b/packages/core/src/_test/setup.ts
@@ -6,7 +6,7 @@ import type { Common } from "@/common/common.js";
 import { LoggerService } from "@/common/logger.js";
 import { MetricsService } from "@/common/metrics.js";
 import { buildOptions } from "@/common/options.js";
-import { TelemetryService } from "@/common/telemetry.js";
+import { createTelemetry } from "@/common/telemetry.js";
 import type { Config } from "@/config/config.js";
 import type { DatabaseConfig } from "@/config/database.js";
 import type { Network } from "@/config/networks.js";
@@ -49,11 +49,12 @@ export function setupContext(context: TestContext) {
     }),
     telemetryDisabled: true,
   };
+  const logger = new LoggerService({ level: "silent" });
   context.common = {
     options,
-    logger: new LoggerService({ level: "silent" }),
+    logger,
     metrics: new MetricsService(),
-    telemetry: new TelemetryService({ options }),
+    telemetry: createTelemetry({ options, logger }),
   };
 }
 
diff --git a/packages/core/src/common/telemetry.test.ts b/packages/core/src/common/telemetry.test.ts
index 9aaffe48..adea4d3a 100644
--- a/packages/core/src/common/telemetry.test.ts
+++ b/packages/core/src/common/telemetry.test.ts
@@ -1,7 +1,10 @@
+import { randomUUID } from "node:crypto";
+import { mkdirSync, rmSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { createTelemetry } from "@/common/telemetry.js";
 import { beforeEach, expect, test, vi } from "vitest";
 
-import { TelemetryService } from "@/common/telemetry.js";
-
 const fetchSpy = vi.fn();
 
 beforeEach(() => {
@@ -10,30 +13,117 @@ beforeEach(() => {
   return () => vi.unstubAllGlobals();
 });
 
-test("events are processed", async (context) => {
-  const options = { ...context.common.options, telemetryDisabled: false };
-  const telemetry = new TelemetryService({ options });
+beforeEach((context) => {
+  const tempDir = path.join(os.tmpdir(), randomUUID());
+  mkdirSync(tempDir, { recursive: true });
+
+  context.common.options = {
+    ...context.common.options,
+    telemetryDisabled: false,
+    telemetryConfigDir: tempDir,
+  };
+
+  return async () => {
+    rmSync(tempDir, { force: true, recursive: true });
+  };
+});
+
+test("telemetry without existing conf creates new conf", async (context) => {
+  const telemetry = createTelemetry({
+    options: context.common.options,
+    logger: context.common.logger,
+  });
+
+  telemetry.record({
+    name: "lifecycle:heartbeat_send",
+    properties: { duration_seconds: process.uptime() },
+  });
+
+  await telemetry.kill();
 
-  telemetry.record({ event: "test", properties: { test: "data" } });
-  await telemetry.flush();
+  expect(fetchSpy).toHaveBeenCalledTimes(1);
 
-  expect(fetchSpy).toHaveBeenCalled();
+  const fetchUrl = fetchSpy.mock.calls[0][0];
+  expect(fetchUrl).toBe(context.common.options.telemetryUrl);
 
   const fetchBody = JSON.parse(fetchSpy.mock.calls[0][1].body);
   expect(fetchBody).toMatchObject({
-    anonymousId: expect.any(String),
-    context: expect.anything(),
-    event: "test",
-    properties: { test: "data" },
+    distinctId: expect.any(String),
+    event: "lifecycle:heartbeat_send",
+    properties: { duration_seconds: expect.any(Number) },
+  });
+});
+
+test("telemetry does not submit events if telemetry is disabled", async (context) => {
+  const telemetry = createTelemetry({
+    options: { ...context.common.options, telemetryDisabled: true },
+    logger: context.common.logger,
+  });
+
+  telemetry.record({
+    name: "lifecycle:session_start",
+    properties: { cli_command: "test" },
   });
+  telemetry.record({
+    name: "lifecycle:heartbeat_send",
+    properties: { duration_seconds: process.uptime() },
+  });
+
+  await telemetry.kill();
+
+  expect(fetchSpy).toHaveBeenCalledTimes(0);
+});
+
+test("telemetry throws if event is submitted after kill", async (context) => {
+  const telemetry = createTelemetry({
+    options: context.common.options,
+    logger: context.common.logger,
+  });
+
+  for (let i = 0; i < 5; i++) {
+    telemetry.record({
+      name: "lifecycle:heartbeat_send",
+      properties: { duration_seconds: process.uptime() },
+    });
+  }
+
+  await telemetry.kill();
+
+  expect(fetchSpy).toHaveBeenCalledTimes(5);
+
+  expect(() =>
+    telemetry.record({
+      name: "lifecycle:heartbeat_send",
+      properties: { duration_seconds: process.uptime() },
+    }),
+  ).toThrow(
+    "Invariant violation, attempted to record event after telemetry service was killed",
+  );
+
+  expect(fetchSpy).toHaveBeenCalledTimes(5);
 });
 
-test("events are not processed if telemetry is disabled", async (context) => {
-  const options = { ...context.common.options, telemetryDisabled: true };
-  const telemetry = new TelemetryService({ options });
+test("telemetry kill clears enqueued events", async (context) => {
+  const telemetry = createTelemetry({
+    options: context.common.options,
+    logger: context.common.logger,
+  });
+
+  // Mock fetch to take 500ms to complete.
+  fetchSpy.mockImplementation(
+    () => new Promise((resolve) => setTimeout(resolve, 500)),
+  );
+
+  for (let i = 0; i < 100; i++) {
+    telemetry.record({
+      name: "lifecycle:heartbeat_send",
+      properties: { duration_seconds: process.uptime() },
+    });
+  }
 
-  telemetry.record({ event: "test" });
-  await telemetry.flush();
+  await telemetry.kill();
 
-  expect(fetchSpy).not.toHaveBeenCalled();
+  // Only 10 of the 100 requests should have been completed
+  // because the queue has a concurrency of 10.
+  expect(fetchSpy).toHaveBeenCalledTimes(10);
 });