Chrome Extension Analytics Telemetry — Best Practices

30 min read

Extension Analytics and Telemetry

Overview

Understanding how users interact with your extension is critical for prioritizing features and catching regressions. However, Chrome extensions face unique constraints: no third-party analytics scripts in service workers, strict Content Security Policy, and heightened user expectations around privacy. This guide covers production patterns for building a privacy-respecting, first-party analytics system entirely within your extension.

Key principle: Collect the minimum data needed to make product decisions. Never collect PII, browsing history, or page content. Always provide a clear opt-out.


Pattern 1: Privacy-Respecting Analytics Architecture

Build a self-contained analytics layer that runs entirely in your service worker:

// analytics/core.ts
import { createStorage, defineSchema } from "@theluckystrike/webext-storage";

interface AnalyticsEvent {
  name: string;
  properties: Record<string, string | number | boolean>;
  timestamp: number;
  sessionId: string;
}

interface AnalyticsConfig {
  endpoint: string;
  flushIntervalMs: number;
  maxBatchSize: number;
  enabled: boolean;
}

const schema = defineSchema({
  analyticsConsent: {
    granted: false,
    decidedAt: 0,
  },
  analyticsQueue: [] as AnalyticsEvent[],
  analyticsSession: {
    id: "",
    startedAt: 0,
  },
});

const storage = createStorage({ schema, area: "local" });

export class Analytics {
  private config: AnalyticsConfig;
  private sessionId = "";

  constructor(config: AnalyticsConfig) {
    this.config = config;
  }

  async init() {
    const consent = await storage.get("analyticsConsent");
    if (!consent?.granted) {
      this.config.enabled = false;
      return;
    }

    // Generate or resume session
    const session = await storage.get("analyticsSession");
    const now = Date.now();
    const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes

    if (session?.id && now - session.startedAt < SESSION_TIMEOUT) {
      this.sessionId = session.id;
    } else {
      this.sessionId = crypto.randomUUID();
      await storage.set("analyticsSession", { id: this.sessionId, startedAt: now });
    }

    // Schedule periodic flush
    chrome.alarms.create("analytics-flush", {
      periodInMinutes: this.config.flushIntervalMs / 60_000,
    });
  }

  async track(name: string, properties: Record<string, string | number | boolean> = {}) {
    if (!this.config.enabled) return;

    const event: AnalyticsEvent = {
      name,
      properties: {
        ...properties,
        extensionVersion: chrome.runtime.getManifest().version,
      },
      timestamp: Date.now(),
      sessionId: this.sessionId,
    };

    const queue = (await storage.get("analyticsQueue")) ?? [];
    queue.push(event);
    await storage.set("analyticsQueue", queue);

    // Auto-flush if batch is full
    if (queue.length >= this.config.maxBatchSize) {
      await this.flush();
    }
  }

  async flush() {
    const queue = (await storage.get("analyticsQueue")) ?? [];
    if (queue.length === 0) return;

    // Clear the queue immediately to avoid double-sends
    await storage.set("analyticsQueue", []);

    try {
      const response = await fetch(this.config.endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          events: queue,
          sentAt: Date.now(),
        }),
      });

      if (!response.ok) {
        // Put events back on failure
        const current = (await storage.get("analyticsQueue")) ?? [];
        await storage.set("analyticsQueue", [...queue, ...current]);
      }
    } catch {
      // Network error — re-enqueue
      const current = (await storage.get("analyticsQueue")) ?? [];
      await storage.set("analyticsQueue", [...queue, ...current]);
    }
  }
}

// Singleton
export const analytics = new Analytics({
  endpoint: "https://api.yourextension.com/v1/events",
  flushIntervalMs: 5 * 60_000, // 5 minutes
  maxBatchSize: 25,
  enabled: true,
});

Pattern 2: Event Tracking Without Third-Party Scripts

Chrome extensions cannot load remote scripts in service workers. All tracking must be first-party:

// analytics/tracker.ts

// Type-safe event catalog — define every event your extension can emit
type EventMap = {
  "extension.installed": { source: "store" | "sideload" | "update" };
  "extension.updated": { from: string; to: string };
  "feature.used": { feature: string; duration_ms?: number };
  "popup.opened": Record<string, never>;
  "popup.action": { action: string; target: string };
  "setting.changed": { key: string; value: string };
  "error.occurred": { code: string; message: string; fatal: boolean };
};

export function createTracker(analytics: Analytics) {
  return {
    track<K extends keyof EventMap>(event: K, properties: EventMap[K]) {
      return analytics.track(event, properties as Record<string, string | number | boolean>);
    },
  };
}

// background.ts — Wire up lifecycle events
const tracker = createTracker(analytics);

chrome.runtime.onInstalled.addListener((details) => {
  if (details.reason === "install") {
    tracker.track("extension.installed", { source: "store" });
  } else if (details.reason === "update") {
    tracker.track("extension.updated", {
      from: details.previousVersion ?? "unknown",
      to: chrome.runtime.getManifest().version,
    });
  }
});
// popup.ts — Track popup interactions
import { createMessenger } from "@theluckystrike/webext-messaging";

type Messages = {
  "analytics:track": {
    request: { event: string; properties: Record<string, string | number | boolean> };
    response: void;
  };
};

const msg = createMessenger<Messages>();

// Popup cannot run analytics directly (short-lived), so relay to background
function trackPopupEvent(action: string, target: string) {
  msg.send("analytics:track", {
    event: "popup.action",
    properties: { action, target },
  });
}

document.getElementById("settings-btn")?.addEventListener("click", () => {
  trackPopupEvent("click", "settings-btn");
});

Pattern 3: Feature Usage Measurement

Track which features are used, how often, and for how long:

// analytics/features.ts

interface FeatureTimer {
  feature: string;
  startedAt: number;
}

const activeTimers = new Map<string, FeatureTimer>();

export function startFeatureTimer(feature: string) {
  activeTimers.set(feature, { feature, startedAt: Date.now() });
}

export function stopFeatureTimer(feature: string): number | null {
  const timer = activeTimers.get(feature);
  if (!timer) return null;

  activeTimers.delete(feature);
  const duration = Date.now() - timer.startedAt;

  analytics.track("feature.used", {
    feature,
    duration_ms: duration,
  });

  return duration;
}

// Track feature adoption over time
export async function trackFeatureAdoption(feature: string) {
  const key = `feature_first_use_${feature}`;
  const result = await chrome.storage.local.get(key);

  if (!result[key]) {
    await chrome.storage.local.set({ [key]: Date.now() });
    analytics.track("feature.first_use", { feature });
  }

  analytics.track("feature.used", { feature });
}
// Usage in content script or popup
import { startFeatureTimer, stopFeatureTimer, trackFeatureAdoption } from "./analytics/features";

// Example: user opens the annotation tool
async function onAnnotationToolOpened() {
  await trackFeatureAdoption("annotation-tool");
  startFeatureTimer("annotation-tool");
}

function onAnnotationToolClosed() {
  const duration = stopFeatureTimer("annotation-tool");
  // duration is automatically tracked
}

Pattern 4: Error Telemetry and Crash Reporting

Capture unhandled errors and report them without leaking sensitive data:

// analytics/errors.ts

interface ErrorReport {
  code: string;
  message: string;
  stack: string;
  context: string;
  fatal: boolean;
}

function sanitizeStack(stack: string): string {
  // Remove file paths that might contain usernames
  return stack.replace(/chrome-extension:\/\/[a-z]+\//g, "ext://");
}

function sanitizeMessage(message: string): string {
  // Strip potential PII like URLs, emails, file paths
  return message
    .replace(/https?:\/\/[^\s]+/g, "[URL]")
    .replace(/[\w.-]+@[\w.-]+/g, "[EMAIL]")
    .replace(/\/Users\/[^\s/]+/g, "/Users/[REDACTED]");
}

export function reportError(error: Error, context: string, fatal = false) {
  const report: ErrorReport = {
    code: error.name,
    message: sanitizeMessage(error.message),
    stack: sanitizeStack(error.stack ?? ""),
    context,
    fatal,
  };

  analytics.track("error.occurred", {
    code: report.code,
    message: report.message.slice(0, 200),
    context: report.context,
    fatal: report.fatal,
  });

  if (fatal) {
    // Flush immediately for fatal errors
    analytics.flush();
  }
}

// Global error handlers for the service worker
self.addEventListener("error", (event) => {
  reportError(
    event.error ?? new Error(event.message),
    "global:error",
    false
  );
});

self.addEventListener("unhandledrejection", (event) => {
  const error =
    event.reason instanceof Error
      ? event.reason
      : new Error(String(event.reason));
  reportError(error, "global:unhandledrejection", false);
});
// Wrap async handlers to catch errors automatically
export function withErrorReporting<T extends (...args: unknown[]) => Promise<unknown>>(
  context: string,
  fn: T
): T {
  return (async (...args: unknown[]) => {
    try {
      return await fn(...args);
    } catch (error) {
      reportError(
        error instanceof Error ? error : new Error(String(error)),
        context
      );
      throw error;
    }
  }) as T;
}

// Usage
chrome.action.onClicked.addListener(
  withErrorReporting("action.onClicked", async (tab) => {
    // If this throws, it gets reported automatically
    await doSomethingWithTab(tab);
  })
);

Implement a transparent consent flow that respects user choice:

// consent.ts
import { createStorage, defineSchema } from "@theluckystrike/webext-storage";

type ConsentStatus = "pending" | "granted" | "denied";

const schema = defineSchema({
  analyticsConsent: {
    status: "pending" as ConsentStatus,
    decidedAt: 0,
    version: 1, // Bump when privacy policy changes
  },
});

const storage = createStorage({ schema, area: "sync" });

const CURRENT_CONSENT_VERSION = 1;

export async function getConsentStatus(): Promise<ConsentStatus> {
  const consent = await storage.get("analyticsConsent");
  if (!consent || consent.version < CURRENT_CONSENT_VERSION) {
    return "pending";
  }
  return consent.status;
}

export async function setConsent(granted: boolean) {
  await storage.set("analyticsConsent", {
    status: granted ? "granted" : "denied",
    decidedAt: Date.now(),
    version: CURRENT_CONSENT_VERSION,
  });

  if (granted) {
    analytics.init();
    analytics.track("consent.granted", {});
  } else {
    // Purge any queued events
    await chrome.storage.local.remove([
      "analyticsQueue",
      "analyticsSession",
    ]);
    analytics.track("consent.denied", {}); // This won't send — analytics is off
  }
}

// Show consent prompt on first install
chrome.runtime.onInstalled.addListener(async (details) => {
  if (details.reason === "install") {
    const status = await getConsentStatus();
    if (status === "pending") {
      // Open a dedicated consent page
      chrome.tabs.create({
        url: chrome.runtime.getURL("consent.html"),
      });
    }
  }
});
<!-- consent.html — Clean, honest consent UI -->
<!DOCTYPE html>
<html>
<head>
  <title>Analytics Preferences</title>
  <style>
    body { font-family: system-ui; max-width: 480px; margin: 40px auto; padding: 0 20px; }
    h1 { font-size: 1.4em; }
    .what-we-collect { background: #f5f5f5; padding: 16px; border-radius: 8px; margin: 16px 0; }
    .what-we-collect li { margin: 4px 0; }
    .actions { display: flex; gap: 12px; margin-top: 24px; }
    button { padding: 10px 24px; border-radius: 6px; font-size: 14px; cursor: pointer; }
    .accept { background: #1a73e8; color: white; border: none; }
    .decline { background: white; border: 1px solid #ddd; }
  </style>
</head>
<body>
  <h1>Help improve this extension</h1>
  <p>We collect anonymous usage data to understand which features matter most. Here is exactly what we track:</p>
  <div class="what-we-collect">
    <ul>
      <li>Which features you use (not what content you view)</li>
      <li>Crash reports and error counts</li>
      <li>Extension version and browser version</li>
    </ul>
  </div>
  <p><strong>We never collect:</strong> URLs you visit, page content, personal information, or browsing history.</p>
  <div class="actions">
    <button class="accept" id="accept">Allow analytics</button>
    <button class="decline" id="decline">No thanks</button>
  </div>
  <script src="consent.js"></script>
</body>
</html>
// consent.ts (UI script)
document.getElementById("accept")?.addEventListener("click", async () => {
  await chrome.runtime.sendMessage({ type: "set-consent", granted: true });
  window.close();
});

document.getElementById("decline")?.addEventListener("click", async () => {
  await chrome.runtime.sendMessage({ type: "set-consent", granted: false });
  window.close();
});

Pattern 6: Batched Event Submission

Queue events locally and submit them in batches to reduce network overhead:

// analytics/batcher.ts

interface BatchConfig {
  maxSize: number;
  maxAgeMs: number;
  endpoint: string;
  headers?: Record<string, string>;
}

export class EventBatcher {
  private queue: AnalyticsEvent[] = [];
  private config: BatchConfig;

  constructor(config: BatchConfig) {
    this.config = config;

    // Flush on alarm
    chrome.alarms.onAlarm.addListener((alarm) => {
      if (alarm.name === "analytics-flush") {
        this.flush();
      }
    });

    // Flush before the service worker terminates
    // (best-effort — no guarantee this fires)
    self.addEventListener("beforeunload", () => {
      if (this.queue.length > 0) {
        this.flushSync();
      }
    });
  }

  async enqueue(event: AnalyticsEvent) {
    this.queue.push(event);

    // Persist to storage in case the service worker restarts
    await chrome.storage.local.set({
      analyticsQueue: this.queue,
    });

    if (this.queue.length >= this.config.maxSize) {
      await this.flush();
    }
  }

  async flush() {
    // Restore from storage (service worker may have restarted)
    const stored = await chrome.storage.local.get("analyticsQueue");
    const events: AnalyticsEvent[] = stored.analyticsQueue ?? [];

    if (events.length === 0) return;

    // Atomic swap: clear storage, then send
    await chrome.storage.local.set({ analyticsQueue: [] });
    this.queue = [];

    // Split into smaller batches if needed
    const CHUNK_SIZE = 50;
    for (let i = 0; i < events.length; i += CHUNK_SIZE) {
      const chunk = events.slice(i, i + CHUNK_SIZE);
      await this.sendBatch(chunk);
    }
  }

  private async sendBatch(events: AnalyticsEvent[]): Promise<boolean> {
    try {
      const response = await fetch(this.config.endpoint, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          ...this.config.headers,
        },
        body: JSON.stringify({
          batch: events,
          sentAt: new Date().toISOString(),
          sdk: "webext-analytics/1.0",
        }),
      });

      return response.ok;
    } catch {
      // Re-enqueue on failure
      const stored = await chrome.storage.local.get("analyticsQueue");
      const current: AnalyticsEvent[] = stored.analyticsQueue ?? [];
      await chrome.storage.local.set({
        analyticsQueue: [...events, ...current],
      });
      return false;
    }
  }

  /** Synchronous fallback using sendBeacon (last resort) */
  private flushSync() {
    if (this.queue.length === 0) return;
    const payload = JSON.stringify({ batch: this.queue });
    navigator.sendBeacon(this.config.endpoint, payload);
    this.queue = [];
  }
}

Pattern 7: Session and Daily Active User Tracking

Count active users without storing any user-identifying information:

// analytics/usage.ts

interface UsageStats {
  installId: string;        // Random ID, not tied to any account
  firstSeenDate: string;    // YYYY-MM-DD
  lastActiveDate: string;   // YYYY-MM-DD
  totalSessions: number;
  daysActive: number;
}

async function getOrCreateInstallId(): Promise<string> {
  const result = await chrome.storage.local.get("installId");
  if (result.installId) return result.installId;

  // Generate a random, non-identifying ID
  const id = crypto.randomUUID();
  await chrome.storage.local.set({ installId: id });
  return id;
}

function todayString(): string {
  return new Date().toISOString().slice(0, 10);
}

export async function recordDailyActive() {
  const installId = await getOrCreateInstallId();
  const today = todayString();

  const result = await chrome.storage.local.get("usageStats");
  const stats: UsageStats = result.usageStats ?? {
    installId,
    firstSeenDate: today,
    lastActiveDate: "",
    totalSessions: 0,
    daysActive: 0,
  };

  // Only count once per day
  if (stats.lastActiveDate === today) return;

  stats.lastActiveDate = today;
  stats.daysActive++;
  stats.totalSessions++;

  await chrome.storage.local.set({ usageStats: stats });

  analytics.track("dau.ping", {
    installId: stats.installId,
    daysActive: stats.daysActive,
    daysSinceInstall: daysBetween(stats.firstSeenDate, today),
  });
}

function daysBetween(a: string, b: string): number {
  const msPerDay = 86_400_000;
  return Math.floor((new Date(b).getTime() - new Date(a).getTime()) / msPerDay);
}

// Fire on service worker startup
chrome.runtime.onStartup.addListener(() => {
  recordDailyActive();
});

// Also fire on install/update
chrome.runtime.onInstalled.addListener(() => {
  recordDailyActive();
});
// Session tracking with idle detection
let sessionStart = Date.now();

chrome.idle.onStateChanged.addListener((state) => {
  if (state === "active") {
    // User returned — start a new session
    sessionStart = Date.now();
    analytics.track("session.start", {});
  } else if (state === "idle" || state === "locked") {
    // User left — end the session
    const duration = Date.now() - sessionStart;
    analytics.track("session.end", {
      duration_ms: duration,
    });
  }
});

// Set idle detection threshold (seconds)
chrome.idle.setDetectionInterval(300); // 5 minutes

Pattern 8: A/B Testing Infrastructure for Extensions

Run experiments to test UI variations and feature flags:

// analytics/experiments.ts
import { createStorage, defineSchema } from "@theluckystrike/webext-storage";

interface Experiment {
  name: string;
  variants: string[];
  weights: number[];    // Must sum to 1.0
  active: boolean;
}

interface ExperimentAssignment {
  variant: string;
  assignedAt: number;
  experiment: string;
}

const schema = defineSchema({
  experimentAssignments: {} as Record<string, ExperimentAssignment>,
});

const storage = createStorage({ schema, area: "local" });

// Define experiments in code (or fetch from a server)
const EXPERIMENTS: Experiment[] = [
  {
    name: "onboarding-flow",
    variants: ["control", "streamlined", "interactive"],
    weights: [0.34, 0.33, 0.33],
    active: true,
  },
  {
    name: "popup-layout",
    variants: ["list", "grid"],
    weights: [0.5, 0.5],
    active: true,
  },
];

function selectVariant(experiment: Experiment): string {
  const rand = Math.random();
  let cumulative = 0;

  for (let i = 0; i < experiment.variants.length; i++) {
    cumulative += experiment.weights[i];
    if (rand < cumulative) {
      return experiment.variants[i];
    }
  }

  return experiment.variants[experiment.variants.length - 1];
}

export async function getVariant(experimentName: string): Promise<string | null> {
  const experiment = EXPERIMENTS.find((e) => e.name === experimentName);
  if (!experiment || !experiment.active) return null;

  const assignments = (await storage.get("experimentAssignments")) ?? {};

  // Return existing assignment if present (sticky bucketing)
  if (assignments[experimentName]) {
    return assignments[experimentName].variant;
  }

  // Assign a new variant
  const variant = selectVariant(experiment);
  assignments[experimentName] = {
    variant,
    assignedAt: Date.now(),
    experiment: experimentName,
  };

  await storage.set("experimentAssignments", assignments);

  analytics.track("experiment.assigned", {
    experiment: experimentName,
    variant,
  });

  return variant;
}

export async function trackExperimentExposure(experimentName: string) {
  const variant = await getVariant(experimentName);
  if (!variant) return;

  analytics.track("experiment.exposed", {
    experiment: experimentName,
    variant,
  });
}

export async function trackExperimentConversion(
  experimentName: string,
  metric: string,
  value: number = 1
) {
  const variant = await getVariant(experimentName);
  if (!variant) return;

  analytics.track("experiment.conversion", {
    experiment: experimentName,
    variant,
    metric,
    value,
  });
}
// Usage in popup or content script
const layout = await getVariant("popup-layout");

if (layout === "grid") {
  renderGridLayout();
} else {
  renderListLayout();
}

await trackExperimentExposure("popup-layout");

// When the user completes a desired action
document.getElementById("save-btn")?.addEventListener("click", async () => {
  await trackExperimentConversion("popup-layout", "save-click");
});

Summary

Pattern Use Case
Privacy-first architecture Self-contained analytics with no third-party dependencies
First-party event tracking Type-safe event catalog relayed through the service worker
Feature usage measurement Track adoption rates and time-in-feature metrics
Error telemetry Sanitized crash reports without leaking PII
Consent management Transparent opt-in/opt-out with versioned privacy policy
Batched submission Queue events locally and flush on a timer or threshold
DAU/session tracking Count active users with anonymous install IDs
A/B testing Sticky variant assignment with exposure and conversion tracking

Extension analytics must be built from scratch because third-party scripts (Google Analytics, Mixpanel, etc.) cannot run in service workers. The patterns above give you the same capabilities – event tracking, error reporting, experimentation – while keeping the user in full control of their data. -e —

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.