- Analytics
- Privacy
- Chrome Web Store
How to add Google Analytics 4 to a Manifest V3 Chrome extension without exposing your API secret
Add GA4 to a Manifest V3 extension with the Measurement Protocol, keep the API secret off the client with a relay, and fix events that never show up.
- Author
- Extenify Team
- Published
- Reading time
- 14 min read

On this page
"Add Google Analytics into a Chrome extension using Manifest V3" has been one of the most viewed extension questions on Stack Overflow since the Manifest V3 migration, and the follow-ups are just as telling: events that never appear, sessions that never count as engaged, and a tracking snippet that the browser refuses to load. The cause is the same every time. The usual gtag.js setup depends on loading a script from Google's servers, and Manifest V3 does not allow remotely hosted code.
The supported route is the GA4 Measurement Protocol: you send events to Google Analytics as plain HTTP requests. Chrome documents it in its Google Analytics 4 guide for extensions, but the sample puts the Measurement Protocol API secret inside the extension, which Google's own Measurement Protocol documentation says not to do. This guide builds the same setup with one change that fixes that, then covers the validation and troubleshooting steps that the official sample skips.
Key takeaways
gtag.jsand Google Tag Manager do not work in Manifest V3 extensions because they load remote code. Use the GA4 Measurement Protocol instead.- Google says the
api_secretis private and should not ship in client-side code. Send events to a small relay you control and let the relay add the secret.- Send a
session_idand a realengagement_time_msecwith every event, or Realtime stays empty and sessions never count as engaged.- The production endpoint never returns an error for a bad payload. Test every event against the validation server first.
- Track feature use, not browsing. Under the 2026 Chrome Web Store rules, every bit of data you collect must be necessary for your single purpose and disclosed.
Why gtag.js and Tag Manager break in Manifest V3
A Manifest V3 extension must ship all of its code in the package. The gtag.js snippet works by injecting a script from googletagmanager.com, which is exactly what the extension Content Security Policy blocks. Older answers that tell you to loosen the CSP for Google's domain were written for Manifest V2 and no longer apply. Tag Manager has the same problem: developers in the Chromium Extensions group reported Content Security Policy errors when trying to initialize it inside an extension.
The Measurement Protocol needs no script at all. It is an HTTPS endpoint that accepts a JSON body describing one or more events. Your extension code builds that body and sends it with fetch(), from a popup, an options page, a side panel or the background service worker.
The architecture: extension, relay, Google Analytics
The Measurement Protocol authenticates requests with two values in the URL: the measurement_id of a web data stream and an api_secret. Google's documentation is direct about the second: "The api_secret is private. Don't expose it in the client-side code of your website or app," because exposing it "allows unauthorized parties to send arbitrary or spam data." Anything shipped in an extension package can be read by anyone who downloads it, so a secret in the bundle is public.
So the setup in this guide has three parts:
- The extension keeps a random client ID and a session ID, queues events in the service worker and sends them in small batches.
- A relay you host (a few lines on Cloudflare Workers, a serverless function or your existing API) checks the request, keeps only events on an allowlist and forwards them to Google Analytics with the secret added on the server.
- Google Analytics 4 receives the events as if they came from a web stream.
A relay does not make spam impossible, since anyone can still call your relay. What it gives you is control: you can rotate the secret without shipping an extension update, reject unknown event names, cap payload sizes and add rate limits.
Step 1: Create the GA4 property, stream and API secret
- In Google Analytics, create a property for the extension. Keep it separate from your website property so extension events do not distort site reports.
- Under Admin, open Data streams and add a Web stream. The URL can be your product site; the extension never loads anything from it.
- Copy the Measurement ID (
G-XXXXXXXXXX). - In the stream details, open Measurement Protocol API secrets and create a secret. This needs the Editor or Administrator role. Store it in your relay's secret store, not in the repository.
If your users are mainly in the European Union and you want collection to go through the EU endpoint, Google added https://region1.google-analytics.com/mp/collect in May 2025, according to the Measurement Protocol changelog.
This property is different from the one the Chrome Web Store can create for your listing. That one measures visits and installs on the store page; this one measures what happens inside the extension. The listing side is covered in how to measure your Chrome Web Store conversion rate.
Step 2: Client ID and session ID in the extension
Google Analytics groups events by client_id, and sessions by session_id. An extension has no cookies to hold them, so store them in extension storage. Add the storage permission (it shows no install warning) and, for the batching below, alarms.
{
"manifest_version": 3,
"permissions": ["storage", "alarms"],
"background": { "service_worker": "background.js", "type": "module" }
}
The client ID is random and lives in chrome.storage.local, so it survives browser restarts but not a reinstall. The session ID lives in chrome.storage.session, which is cleared when the browser closes, and expires after 30 minutes without activity, the same rule Chrome's guide uses.
// analytics-ids.js
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
export async function getClientId() {
const { gaClientId } = await chrome.storage.local.get('gaClientId');
if (gaClientId) return gaClientId;
const random = crypto.getRandomValues(new Uint32Array(1))[0];
const clientId = `${random}.${Math.floor(Date.now() / 1000)}`;
await chrome.storage.local.set({ gaClientId: clientId });
return clientId;
}
export async function getSessionId() {
const now = Date.now();
const { gaSession } = await chrome.storage.session.get('gaSession');
if (gaSession && now - gaSession.lastSeen < SESSION_TIMEOUT_MS) {
await chrome.storage.session.set({ gaSession: { ...gaSession, lastSeen: now } });
return gaSession.id;
}
const id = String(now);
await chrome.storage.session.set({ gaSession: { id, lastSeen: now } });
return id;
}
The session ID must match ^\d+$, per the Measurement Protocol reference, so a millisecond timestamp works.
Step 3: Queue and send events from the service worker
Sending one request per click works, but a queue is kinder to the user's network and survives the service worker being stopped between events. The worker below keeps a queue in session storage, flushes it every minute through chrome.alarms, and sends at most 25 events per request, which is the Measurement Protocol limit.
// background.js
import { getClientId, getSessionId } from './analytics-ids.js';
const RELAY_URL = 'https://analytics.example.com/collect';
const MAX_EVENTS_PER_REQUEST = 25;
// Storage reads and writes are async, so serialize queue access to avoid
// two events overwriting each other.
let queueLock = Promise.resolve();
function withQueue(fn) {
const run = queueLock.then(fn);
queueLock = run.catch(() => {});
return run;
}
export function track(name, params = {}) {
return withQueue(async () => {
const event = {
name,
params: {
...params,
session_id: await getSessionId(),
engagement_time_msec: params.engagement_time_msec ?? 100,
},
};
const { gaQueue = [] } = await chrome.storage.session.get('gaQueue');
gaQueue.push(event);
await chrome.storage.session.set({ gaQueue });
return gaQueue.length;
}).then((length) => (length >= MAX_EVENTS_PER_REQUEST ? flush() : undefined));
}
async function flush() {
const gaQueue = await withQueue(async () => {
const { gaQueue: queued = [] } = await chrome.storage.session.get('gaQueue');
await chrome.storage.session.set({ gaQueue: [] });
return queued;
});
if (gaQueue.length === 0) return;
const clientId = await getClientId();
for (let i = 0; i < gaQueue.length; i += MAX_EVENTS_PER_REQUEST) {
const events = gaQueue.slice(i, i + MAX_EVENTS_PER_REQUEST);
try {
await fetch(RELAY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId, events }),
});
} catch {
// Offline or relay down: drop the batch rather than retry forever.
}
}
}
// Creating an alarm that already exists resets its timer, and the service
// worker restarts often, so only create it when it is missing.
chrome.alarms.get('ga-flush').then((alarm) => {
if (!alarm) chrome.alarms.create('ga-flush', { periodInMinutes: 1 });
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'ga-flush') flush();
});
chrome.runtime.onInstalled.addListener(({ reason, previousVersion }) => {
if (reason === 'install') track('extension_install');
if (reason === 'update') track('extension_update', { previous_version: previousVersion });
});
chrome.runtime.onMessage.addListener((message) => {
if (message?.type === 'ga-event') track(message.name, message.params);
});
Pages such as the popup or options page do not need their own copy of this code. They send a message, and the worker adds the IDs and queues it:
// popup.js
chrome.runtime.sendMessage({
type: 'ga-event',
name: 'page_view',
params: { page_title: 'Popup', page_location: '/popup.html' },
});
Note that page_location is your extension page, never the URL of the tab the user is on. More on that below.
Step 4: The relay that holds the secret
Here is a complete relay as a Cloudflare Worker. It accepts requests only from your extension origins, keeps allowlisted events, adds the country Cloudflare already knows from the connection, and forwards the payload with the secret from the Worker's environment.
// worker.js
const ALLOWED_EVENTS = new Set([
'page_view', 'extension_install', 'extension_update', 'feature_used', 'popup_closed',
]);
function allowedOrigin(origin, env) {
if (env.CHROMIUM_ORIGINS.split(',').includes(origin)) return true;
return origin.startsWith('moz-extension://');
}
export default {
async fetch(request, env) {
const origin = request.headers.get('Origin') ?? '';
if (!allowedOrigin(origin, env)) return new Response(null, { status: 403 });
const cors = {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'Content-Type',
Vary: 'Origin',
};
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
if (request.method !== 'POST') return new Response(null, { status: 405, headers: cors });
const body = await request.json().catch(() => null);
if (!body || typeof body.client_id !== 'string' || !Array.isArray(body.events)) {
return new Response(null, { status: 400, headers: cors });
}
const events = body.events.filter((e) => ALLOWED_EVENTS.has(e?.name)).slice(0, 25);
if (events.length === 0) return new Response(null, { status: 204, headers: cors });
const payload = { client_id: body.client_id.slice(0, 64), events };
const country = request.cf?.country;
if (typeof country === 'string' && /^[A-Z]{2}$/.test(country) && country !== 'XX') {
payload.user_location = { country_id: country };
}
const url = `https://www.google-analytics.com/mp/collect?measurement_id=${env.GA_MEASUREMENT_ID}&api_secret=${env.GA_API_SECRET}`;
await fetch(url, { method: 'POST', body: JSON.stringify(payload) });
return new Response(null, { status: 204, headers: cors });
},
};
Set the values with wrangler secret put GA_API_SECRET and plain variables for the rest. A few details matter:
- Chrome and Edge give your extension different IDs, so list both origins, for example
chrome-extension://<chrome-id>,chrome-extension://<edge-id>. - Firefox uses a random UUID per installation in
moz-extension://origins, so you cannot allowlist an exact origin there. The prefix check above is the practical option. - The
Originheader is a filter, not authentication. A script outside a browser can set any header. Add a rate limit on the route if spam becomes a problem. - Location is your choice. Events that arrive only through the Measurement Protocol have no browser tag for Google Analytics to borrow location from. Since May 2025 the protocol accepts
user_locationandip_override, anduser_locationwins if you send both. Sending only a country code, as above, gives you country reports without forwarding anyone's IP address.
Step 5: Validate before you ship
The production endpoint is silent about mistakes. Google's validation guide says the Measurement Protocol "does not return HTTP error codes, even if an event is malformed or missing required parameters." Point the relay at the validation server while you develop:
const debugUrl = `https://www.google-analytics.com/debug/mp/collect?measurement_id=${env.GA_MEASUREMENT_ID}&api_secret=${env.GA_API_SECRET}`;
const res = await fetch(debugUrl, {
method: 'POST',
body: JSON.stringify({ ...payload, validation_behavior: 'ENFORCE_RECOMMENDATIONS' }),
});
console.log(await res.json()); // { validationMessages: [...] }
An empty validationMessages array means the payload is well formed. Two limits of the validation server catch people out: events sent to it never appear in reports, and it does not check the api_secret or measurement ID, so a typo there still passes validation and then fails silently in production.
Once validation is clean, switch back to /mp/collect and watch the Realtime report. Standard reports take longer: Google Analytics says data processing can take 24 to 48 hours, which explains the common "measurement protocol events only in real-time, not in reports" questions from the first day of testing.
Why your events or sessions do not show up
Most broken setups fail on one of these. Check them in order:
- No
session_idorengagement_time_msec. Chrome's guide notes both are needed for user activity to appear in standard reports such as Realtime. - A reserved event name.
error,session_start,user_engagement,first_openandapp_installare among the names the Measurement Protocol rejects. Useextension_errororextension_installinstead. - Names or values over the limits. Event and parameter names are capped at 40 characters, parameter values at 100 characters, and each event at 25 parameters. Longer values are not reported as errors in production.
- Wrong measurement ID or secret. The validation server will not tell you. Compare both with the stream settings.
- Late timestamps. If you replay a stored queue,
timestamp_microscan only go back 72 hours.
The other frequent complaint, raised in a Stack Overflow question titled "Measurement protocol to GA4 through chrome extension does not show engaged sessions count", comes from the default engagement_time_msec of 100. Google Analytics counts a session as engaged when it lasts longer than 10 seconds, has a key event, or has two or more page or screen views. If every event claims 100 milliseconds of engagement and the popup sends one page view, almost no session qualifies.
Measure real time instead. A long-lived port from the popup lets the service worker see when the popup closes, even though the popup itself has no chance to send a final request:
// popup.js
chrome.runtime.connect({ name: 'popup' });
// background.js
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== 'popup') return;
const openedAt = Date.now();
port.onDisconnect.addListener(() => {
track('popup_closed', { engagement_time_msec: Date.now() - openedAt });
});
});
Marking one meaningful event as a key event, such as the core action your extension exists for, also makes sessions where users did real work count as engaged.
What to track, and what not to
Measurement in an extension is held to a stricter standard than on a website. Chrome Web Store's Limited Use policy allows data "necessary for the extension's disclosed single purpose, including related operational purposes, such as maintaining, securing, or measuring the performance and reliability of those features," and the July 2026 policy update requires that all data collection be prominently disclosed. The details are in Chrome Web Store changes in 2026.
A defensible event plan for most extensions fits on one screen:
| Event | Parameters | Answers |
|---|---|---|
extension_install, extension_update |
previous_version |
Which versions people run, and when updates land |
page_view |
page_title, extension page path only |
Which extension screens get used |
feature_used |
feature (a fixed list of names) |
Which features matter, and which ones nobody finds |
popup_closed |
engagement_time_msec |
How long people spend in the popup |
extension_error |
code, version |
Whether a release broke something |
And a list of things that should never leave the browser through analytics:
- URLs, titles or content of the pages the user visits.
- Search terms, form input or anything typed.
- Stack traces with file paths or user data in them. Chrome's guide itself warns that error details can leak personal information; send an error code and a version instead.
- Any account identifier, email or
user_idthat ties events to a person.
On Firefox, also declare the collection in the manifest and respect the user's choice. Firefox data collection consent for extensions shows how to gate sending on that consent. If you want usage numbers without any analytics vendor at all, the approach in measuring extension retention and uninstall rate uses a single daily ping and no identifier.
Reports to set up first
With events flowing, a few reports answer most product questions:
- Feature adoption. An exploration of
feature_usedbroken down by thefeatureparameter, filtered to the last 28 days. Registerfeatureas a custom dimension first, or it will not be available. - Version health.
extension_errorcounts byversion, next toextension_updatecounts by version, so you see error rates per release rather than raw totals. - Engagement trend. Engaged sessions per user, week over week, once real engagement time is flowing.
Two settings are worth changing on day one: raise event data retention from the default two months to 14 months under Admin, so year-over-year comparisons are possible, and add internal traffic filters for your own test installs.
Frequently asked questions
Can I use gtag.js in a Manifest V3 Chrome extension?
No. gtag.js loads code from Google's servers, and Manifest V3 extensions may not run remotely hosted code. Use the Measurement Protocol to send events with fetch() instead.
Is it safe to put the Measurement Protocol API secret in my extension?
Google's documentation says the api_secret is private and should not be exposed in client-side code, because anyone who has it can send spam data to your property. Keep it on a small relay that forwards events to Google Analytics.
Why do my Measurement Protocol events only show up in Realtime?
Standard reports can take 24 to 48 hours to process. If events never appear in Realtime either, check that each event has a session_id and engagement_time_msec, that the event name is not reserved, and that the measurement ID and secret are correct.
Do I need a privacy policy to use Google Analytics in an extension?
If your extension collects user data, Chrome Web Store policy requires you to disclose it, and Firefox asks you to declare data collection in the manifest. Analytics events tied to a client ID count as collected data, so disclose them and keep them limited to what your single purpose needs.
Does this work in Edge and Firefox?
Yes. The same code runs in Microsoft Edge, and in Firefox with the browser namespace or the same chrome calls. List your Edge extension origin in the relay, and allow moz-extension:// origins for Firefox, where the origin differs for every installation.
Summary
Google Analytics 4 works in a Manifest V3 extension once you stop trying to load gtag.js and send Measurement Protocol events yourself. Keep a random client ID and a 30-minute session in extension storage, queue events in the service worker, and send them through a relay that holds the API secret and allowlists event names. Validate every event against the debug endpoint, send real engagement time so sessions count, and keep the event plan small enough to defend in a store review. Your in-product analytics then covers what the store dashboards cannot, while the public side, ratings, reviews and user counts across stores, is what tracking one extension across Chrome, Edge and Firefox is for.
Image credits
- Cover photo: Close-Up Shot of a Laptop Computer by Atlantic Ambience on Pexels, Pexels License.


