Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions analytics-proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# Analytics event streamlining proposal

Based on the GA4 numbers for Jul 3 – Aug 1, 2026: **32.7M events / 15,081 active
users** (~2,165 events per user per month).

## Summary

About **92% of the event volume is service worker lifecycle noise**, not user
behavior. Roughly 30M of the 32.7M events come from two calls that fire together
every time the MV3 service worker wakes up. Cutting those, collapsing the
`update` triplet, and filtering benign exceptions takes total volume from **32.7M
to ~2.5M/month (a ~93% reduction)** without losing any question we can currently
answer.

That reduction is worth doing on its own merits for GA hygiene, and it happens to
bring PostHog's 1M free tier within reach at a ~40% user sample instead of ~3%.

## Where the volume actually comes from

| Event | Count | Real driver |
|---|---:|---|
| `page_view` | 15M | service worker startup |
| `background-loaded` | 15M | service worker startup |
| `toggle-shortcut` | 1.7M | genuine user action |
| `exception` | 555K | see below |
| `focus-mru` | 360K | genuine user action |
| `missing-recents` | 171K | one third of `updateFromFreshTabs()` |
| `new-tabs` | 171K | one third of `updateFromFreshTabs()` |
| `old-recents` | 171K | one third of `updateFromFreshTabs()` |
| `popup-loaded` | 83K | popup open |
| `previous` | 62K | genuine user action |

Bucketed: **~30M lifecycle (92%)**, **~2.2M real user behavior (7%)**,
**555K exceptions (1.7%)**.

The GA-generated narrative about "power users in Bengaluru averaging 2,012 events"
is an artifact of this. It is measuring how often each user's service worker
respawned — which tracks how much they browse in general, not how much they use
QuicKey. It should not be read as an engagement signal.

## 1. Stop firing on every service worker startup (saves ~30M)

Both calls live in the same `.then()` in the startup chain
(`background.js` / `startup.js`):

```js
tracker.pageview();
tracker.timing("loading", "background-loaded", performance.now());
```

### `page_view` — delete it

This was written when MV2 had a persistent background page that loaded once per
browser session, where a pageview was a reasonable session marker. Under MV3 it
fires on every wakeup and corresponds to nothing the user sees or does. Worse, it
actively corrupts GA4's session and engagement modeling, since GA treats
`page_view` as session activity — so every idle respawn registers as engagement.

### `background-loaded` — keep the metric, sample the sends

The load-time histogram is still worth having, but not at 15M samples/month.
Send it when it carries signal:

- **always** on install, update, and `chrome.runtime.onStartup` (rare,
high-signal, and exactly the cold starts where load time is user-visible)
- **1% sampled** on routine wakeups, to keep a warm-start baseline

~150K samples/month is far more than enough for stable percentiles.

## 2. Answer the "what happened while the popup was open" question properly

The original reason for the extra pageviews was to see which events occurred
while the popup had focus. Pageviews are an indirect and very expensive proxy
for that.

Direct replacement: keep a boolean that flips on popup connect/disconnect (the
`ports` handlers in `background.js` already know exactly when this happens) and
stamp it onto outgoing events as a property.

- **PostHog:** a super property — `posthog.register({ popup_open: true })` on
connect, `false` on disconnect. Every subsequent event carries it, and any
insight can then be broken down or filtered by `popup_open`.
- **GA4:** the same value via `tracker.set("popup_open", …)`, registered as a
custom dimension.

Cost: zero extra events. It also answers the question better than pageviews ever
did, since you get the flag on the events themselves rather than having to infer
adjacency from timestamps.

Additionally, replace the popup open/close pageview pair with a single
`popup_session` event carrying a `ms` duration property — one event instead of
two, and the duration becomes directly chartable instead of requiring GA to
difference two timestamps.

## 3. Collapse the `update` triplet into one event (saves ~340K)

`old-recents`, `new-tabs`, and `missing-recents` are three separate events
describing a single `updateFromFreshTabs()` call, split apart only because GA4
has no clean way to average a metric across events.

```js
tracker.event("update", "recents-updated", {
old_recents: tabIDs.length,
new_tabs: freshTabs.length,
missing: missingCount,
// precomputed so the ratio is chartable without a derived metric
missing_pct: tabIDs.length
? Math.round((missingCount / tabIDs.length) * 100)
: 0
});
```

This is a genuine PostHog advantage worth noting: PostHog charts average, median,
p90, etc. of any numeric property directly, so the workaround that forced three
events in the first place isn't needed there. In GA4 the equivalent requires
registering each as a custom metric.

Bonus: `missing_pct` on a single event is exactly the signal for the
tab-history-loss bug — one insight, "p90 of `missing_pct` after browser
restart," rather than trying to correlate three separate event streams.

## 4. Exceptions: 555K/month needs investigating before anything else

555K exceptions across 15,081 users is ~37 per user per month. That is not a
long tail of rare crashes; something is firing routinely.

### First: make the messages visible (no code change)

`tracker.exception()` sends the message as a `description` **event parameter**,
but GA4 only surfaces parameters in reports once they're registered as a
**custom dimension**. That is very likely why 555K exceptions have gone
un-diagnosed — the data is being collected and discarded at the reporting layer.

**Action: register `description` as a custom dimension in GA4 admin.** It's free
and takes effect immediately (though not retroactively). Do this before writing
any code, so the cleanup is driven by what's actually failing.

One caveat: `description` contains full stack traces up to 2000 chars, which is
very high cardinality — GA will bucket most of it into `(other)`. So also send a
normalized low-cardinality `error_type` (first line / message only, no stack,
no URLs) alongside the full `description`.

### Likely cause

`error-handler.js` reports **every** `unhandledrejection` as an exception with
`fatal: true`. Under MV3 these are routine lifecycle artifacts rather than bugs:

- `Extension context invalidated`
- `The message port closed before a response was received`
- `Could not establish connection. Receiving end does not exist`
- `No tab with id: …` (already filtered in `addTab`, but only there)

These fire whenever the worker is torn down mid-promise, which — per section 1 —
is happening tens of times per user per day.

### Proposed changes

1. Maintain a list of known-benign lifecycle rejection patterns and drop them in
`handleError()` before they reach the tracker.
2. Stop hardcoding `fatal: true`. A port-closed rejection is not fatal, and
marking it so inflates GA's crash-free-user metric.
3. Rate-limit exception reporting per worker lifetime (e.g. max 5), so a single
error loop can't dominate the dataset — or blow a PostHog event budget.

Expected: 555K → well under 50K, with the remainder being real bugs worth acting
on.

## Projected volume after all changes

| Bucket | Before | After |
|---|---:|---:|
| SW lifecycle (`page_view` + `background-loaded`) | 30M | ~150K |
| User actions | ~2.2M | ~2.2M |
| `update` triplet | 513K | 171K |
| Exceptions | 555K | <50K |
| **Total** | **32.7M** | **~2.5M** |

## Implications for PostHog

At ~2.5M/month, the 1M free tier covers a **~40% deterministic user sample**
(~6,000 users at full event fidelity) rather than the ~3% (~450 users) that
current volume would allow. That is a large enough panel for confident funnel,
retention, and path analysis.

Sampling should still be **deterministic per user** — hash the stored `clientID`
UUID and compare to a threshold — so each install is consistently in or out and
event sequences stay intact. Event-level sampling would shred the sequences that
make those analyses possible.

Regardless of sample rate, **set a $0 billing limit in the PostHog project
settings** so overage drops events instead of generating a bill.

## Suggested sequencing

1. Register `description` (and add `error_type`) as GA4 custom dimensions —
free, immediate, and tells us what the 555K exceptions are.
2. Delete the startup `page_view`; sample `background-loaded`. (~92% of volume,
smallest diff of anything here.)
3. Collapse the `update` triplet; add the `popup_open` property.
4. Filter benign lifecycle rejections once step 1 shows what they are.
5. Re-measure for a week, then pick the PostHog sample rate from real numbers.

Steps 1–4 are worth doing whether or not PostHog ever ships.
53 changes: 52 additions & 1 deletion src/js/background/tracker.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import ga4mp from "@/lib/ga4mp";
import { createPostHogClient } from "@/lib/posthog";


// GA event names that PostHog has native equivalents for, which light
// up its built-in web analytics and error tracking UIs
const PostHogEventNames = {
page_view: "$pageview",
exception: "$exception"
};
const PathPattern = /chrome-extension:\/\/[^\n]+\//g;
const MaxStackLength = 2000;
// conditions in the browser rather than bugs in QuicKey: the user's disk is
Expand Down Expand Up @@ -39,7 +46,29 @@ export default class Tracker {
throw new Error("A Google Analytics 4 tracker ID is required.");
}

this.ga4 = ga4mp([id], { ...DefaultSettings, ...settings });
const mergedSettings = { ...DefaultSettings, ...settings };

this.ga4 = ga4mp([id], mergedSettings);
// send every event to PostHog as well, so the two services can
// be compared for a release before GA is dropped. reuse the GA
// client_id as the distinct_id, so if we later cut over fully,
// install continuity is preserved.
// GA custom dimensions have restrictive naming, but PostHog
// properties don't, so send readable names and drop the
// dimension1/2 keys from the PostHog copy
const { dimension1, dimension2, ...posthogProperties } =
mergedSettings.persistentEventParameters ?? {};

this.posthog = createPostHogClient({
distinctID: mergedSettings.client_id,
superProperties: {
...posthogProperties,
version: dimension1,
channel: dimension2,
// PostHog's web analytics UI keys off $current_url
$current_url: posthogProperties.page_location
}
});
this.enabled = enabled;

if (sendPageview) {
Expand All @@ -63,6 +92,26 @@ export default class Tracker {
{
if (this.enabled) {
this.ga4.trackEvent(event, params);

const posthogEvent = PostHogEventNames[event] ?? event;
let posthogParams = params;

if (posthogEvent === "$exception") {
// PostHog's error tracking UI groups on $exception_list,
// so move the potentially long description there instead
// of duplicating it in the payload
const { description, ...rest } = params ?? {};

posthogParams = {
...rest,
$exception_list: [{
type: "Error",
value: description
}]
};
}

this.posthog.capture(posthogEvent, posthogParams);
}
}

Expand All @@ -72,8 +121,10 @@ export default class Tracker {
{
if (name && typeof name == "object") {
Object.entries(name).forEach(([key, value]) => this.ga4.setEventsParameter(key, value));
this.posthog.register(name);
} else {
this.ga4.setEventsParameter(name, value);
this.posthog.register({ [name]: value });
}
}

Expand Down
67 changes: 67 additions & 0 deletions src/js/lib/posthog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// a minimal PostHog client for the extension. the posthog-js SDK is
// DOM-oriented (autocapture, session recording) and assumes a long-lived
// page, neither of which fits an MV3 service worker, so we just post
// events to the HTTP capture API directly, the same way ga4mp.js does
// for GA4. https://posthog.com/docs/api/capture

// the project API key is public and safe to ship in the bundle. until
// a real key is pasted here, the client no-ops, so this file can land
// before the PostHog project is created.
const ApiKey = "phc_REPLACE_WITH_PROJECT_API_KEY";
// use https://eu.i.posthog.com for a project hosted in the EU region
const Host = "https://us.i.posthog.com";


export function createPostHogClient({
distinctID,
superProperties = {} })
{
const properties = { ...superProperties };
const enabled = /^phc_[A-Za-z0-9]+$/.test(ApiKey);


function register(
newProperties)
{
Object.assign(properties, newProperties);
}


function capture(
event,
eventProperties = {})
{
if (!enabled) {
return Promise.resolve();
}

return fetch(`${Host}/i/v0/e/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
// keepalive lets the request finish even if the service
// worker is torn down right after sending
keepalive: true,
body: JSON.stringify({
api_key: ApiKey,
event,
distinct_id: distinctID,
properties: {
...properties,
...eventProperties,
// anonymous events, so no person profiles are created,
// which matches GA's model of unidentified clients
$process_person_profile: false
},
timestamp: new Date().toISOString()
})
})
// analytics must never break the extension
.catch(error => globalThis.DEBUG && console.error("PostHog capture failed", error));
}


return {
register,
capture
};
}
Loading