Sending Analytics from a Service Worker with GA4
Send Google Analytics 4 events from an MV3 extension service worker with the Measurement Protocol: client_id and session_id in storage, engagement time, batching, the debug endpoint and consent gating.
Table of Contents
The marketing site uses Google Analytics 4 and the team wants extension usage in the same property. The standard gtag.js snippet cannot be used: it is a remote script, which MV3 forbids, and it expects a page with window, document and cookies, none of which the service worker has. GA4’s Measurement Protocol is the supported alternative — a plain HTTPS endpoint that accepts JSON events — and it works from a service worker with fetch. Getting useful reports out of it requires supplying, by hand, the identifiers and engagement data that gtag.js normally manages. This guide does that. It belongs to usage analytics and feature flags.
What gtag.js did that you now do yourself
In a web page, gtag.js sets a first-party cookie holding a random client_id, starts a session with a session_id, measures engagement time while the page is visible, and attaches page context to every event. The Measurement Protocol accepts the same concepts as fields in a JSON payload but generates none of them. If you omit session_id and engagement_time_msec, GA4 records events but drops them from most engagement reports, and “active users” look far lower than reality. In an extension, client_id belongs in chrome.storage.local (persistent per install), session_id in chrome.storage.session (reset when the browser restarts, or after inactivity), and engagement time comes from how long your UI was actually open.
Step-by-step: GA4 from the service worker
1. Create a data stream and an API secret
In the GA4 admin, create a Web data stream for the extension (or reuse one), note its Measurement ID (G-XXXXXXX), and under Measurement Protocol API secrets create a secret. Both end up in your extension’s code.
1// analytics/ga4.js
2const MEASUREMENT_ID = "G-XXXXXXX";
3const API_SECRET = "abc123…"; // ships in the package
4const ENDPOINT = `https://www.google-analytics.com/mp/collect?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`;
Execution context: a module imported by the service worker. The API secret is not truly secret — anyone can extract it from the package — and the worst someone can do with it is send fake events to your property. That is an accepted trade-off for client-side Measurement Protocol use; if fake data is a real concern, proxy through your own endpoint and keep the secret on the server.
2. Create a stable client_id per install
1export async function clientId() {
2 let { gaClientId } = await chrome.storage.local.get("gaClientId");
3 if (!gaClientId) {
4 gaClientId = crypto.randomUUID();
5 await chrome.storage.local.set({ gaClientId });
6 }
7 return gaClientId;
8}
Execution context: the service worker. A random UUID generated on first use identifies an installation, not a person, and cannot be linked to anything outside the extension. Never use the user’s Google account id, email, or a hardware identifier. Clearing extension data resets it, which is the correct behaviour.
3. Manage sessions in session storage
1const SESSION_TIMEOUT_MIN = 30;
2
3export async function sessionId() {
4 const now = Date.now();
5 let { gaSession } = await chrome.storage.session.get("gaSession");
6 if (gaSession && now - gaSession.lastSeen < SESSION_TIMEOUT_MIN * 60_000) {
7 gaSession.lastSeen = now;
8 } else {
9 gaSession = { id: String(Math.floor(now / 1000)), lastSeen: now };
10 }
11 await chrome.storage.session.set({ gaSession });
12 return gaSession.id;
13}
Execution context: the service worker. chrome.storage.session survives worker eviction but not a browser restart, which matches GA4’s notion of a session; the thirty-minute inactivity timeout matches GA4’s default. The session id is a Unix timestamp in seconds, as gtag.js uses. Firefox supports storage.session from 115 and Safari from 16.4.
4. Send events with engagement time
1export async function sendGa4(events) {
2 const { consent } = await chrome.storage.local.get("consent");
3 if (consent !== "granted" || events.length === 0) return;
4 const body = {
5 client_id: await clientId(),
6 non_personalized_ads: true,
7 events: await Promise.all(events.slice(0, 25).map(async (e) => ({
8 name: e.name,
9 params: {
10 ...e.params,
11 session_id: await sessionId(),
12 engagement_time_msec: e.engagementMs ?? 100,
13 extension_version: chrome.runtime.getManifest().version,
14 },
15 }))),
16 };
17 const res = await fetch(ENDPOINT, { method: "POST", body: JSON.stringify(body) });
18 return res.status === 204;
19}
Execution context: the service worker. The Measurement Protocol accepts up to 25 events per request; batch from your queue accordingly. The endpoint always returns 2xx for well-formed requests even when events are invalid — validation happens in step 6, not here. non_personalized_ads prevents the data being used for ad personalisation. A small non-zero engagement time is needed for events to count as engaged; pass real durations where you measure them.
5. Measure engagement where it happens
1// popup.js — measure how long the popup was open
2const opened = performance.now();
3addEventListener("pagehide", () => {
4 chrome.runtime.sendMessage({
5 type: "analytics:track", name: "popup_closed",
6 props: {}, engagementMs: Math.round(performance.now() - opened),
7 });
8});
Execution context: the popup. pagehide fires when the popup closes; a message sent from it usually reaches the worker, though delivery is not guaranteed as the page is torn down. For side panels and options pages, accumulate visible time with visibilitychange and send on hide. Never measure engagement inside content scripts — time on third-party pages is browsing behaviour, not extension engagement.
6. Validate payloads with the debug endpoint
1const DEBUG = `https://www.google-analytics.com/debug/mp/collect?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`;
2const res = await fetch(DEBUG, { method: "POST", body: JSON.stringify(body) });
3console.log((await res.json()).validationMessages); // [] when valid
Execution context: the service worker in development builds only. The debug endpoint returns validation messages — reserved event names, parameters too long, missing fields — instead of silently accepting. Events sent there do not appear in reports. Run it in your test suite against a representative payload for every event in your schema.
Common mistakes
- Loading gtag.js from a CDN. Blocked by CSP and a policy violation in review.
- Omitting
session_idorengagement_time_msec. Events arrive but most engagement reports exclude them. - Sending page URLs as
page_location. That is browsing data; GA4 does not need it for extension analytics, and the store will ask why you collect it. - Sending from content scripts. Requests carry the page’s origin and look like third-party tracking on that site.
- Ignoring consent. Every send path must check consent first, including the flush alarm.
Cross-browser variation
- Chrome / Edge: works as written; add
https://www.google-analytics.com/*tohost_permissionsor rely on its permissive CORS. - Firefox: works as written; AMO requires opt-in consent and disclosure for analytics, and newer Firefox versions expect a data-collection declaration in the manifest.
- Safari: works with
fetch; include analytics in the containing app’s App Store privacy label.
Verification
- Send a test event to the debug endpoint and confirm
validationMessagesis empty. - In GA4, open Realtime and trigger an extension action: the event appears within a minute.
- In GA4’s DebugView, temporarily add
debug_mode: 1to params and confirm the session groups events correctly. - Withdraw consent in the options page and confirm, in the worker’s Network panel, that no further requests go to Google Analytics.
FAQ
Can I combine extension and website data in one property?
Yes, as separate data streams in one property, distinguished by stream or by a custom dimension. Do not attempt to link the extension’s client_id with the website’s cookie — that cross-context linking is exactly what reviewers and privacy laws object to.
Is the Measurement Protocol rate-limited?
GA4 applies quotas per property; normal extension volumes are far below them. Batch events and send daily or hourly rather than per event.
Do I need a consent banner like a website?
You need a clear consent choice in the extension — typically in onboarding and the options page. The rules depend on jurisdiction and store; opt-in is the safe default.
Related
- Privacy-preserving usage metrics — deciding what goes into the events.
- Counting active users without tracking — an alternative to client ids.
- Chrome storage session vs local — where the identifiers live.
- Usage analytics and feature flags — the parent topic.