Google Analytics in a Figma plugin: why it breaks and what to do instead
The plugin UI runs in a null-origin iframe with no cookies, so gtag.js fails. What the Measurement Protocol gives you, what it costs you, and the shape of a tool built for the sandbox.
, by Ilia
Every few months someone on the Figma forum pastes the gtag snippet into their plugin's ui.html, opens the console and sees this:
SecurityError: Failed to read the 'cookie' property from 'Document':
Cookies are disabled inside 'data:' URLs.Nothing is wrong with the snippet. Google Analytics assumes a web page, and a plugin is not one. Below is what the plugin runtime looks like from the analytics script's point of view, what you can do about it with Google's own tools, and why I ended up building something else.
Where a plugin actually runs
A Figma plugin is two programs. The main code (code.ts) runs in a sandbox next to the document. It has the figma API and fetch, and that is about it: no DOM, no window, no document, nowhere to put a cookie.
The UI runs in an iframe. Figma takes the HTML string you pass to figma.showUI and loads it into that iframe itself, which is why the page has a data: URL and an origin of null. Cookies, localStorage and IndexedDB all belong to an origin. A null origin owns none of them, so reading any of them throws. The iframe also has no real URL to speak of, no referrer, and it closes the moment the user is done.
Browser analytics was built on exactly those things: a cookie to remember the visitor, a URL to name the page, a referrer to say where they came from, a tab that stays open long enough to build a session.
What gtag.js needs and does not get
The _ga cookie holds the client id. Without it gtag either throws, as above, or mints a new id on every load. Every time someone opens your plugin, Google sees a new user and a new session. Your user count becomes your open count, and retention reports show nothing because nobody ever comes back.
The page_view event wants a page_location. In a plugin that is a data: URL several kilobytes long, so the pages report is garbage. Engagement time is measured by the tab being in the foreground, which a plugin iframe never reports properly. The realtime view, the geography report and the device report all read signals the browser sends with a normal page hit, and a plugin iframe sends them wrong or not at all.
The Measurement Protocol route
GA4 has a server-side door: the Measurement Protocol. You POST JSON to Google with your measurement id, an API secret, a client id you made up, and a list of events. That works from the plugin's main thread, because fetch is available there.
// code.ts
const MEASUREMENT_ID = 'G-XXXXXXX';
const API_SECRET = '...'; // yes, this ends up in your plugin bundle
async function send(name: string, params: Record<string, unknown> = {}) {
const clientId = await getOrCreateClientId(); // figma.clientStorage
await fetch(
'https://www.google-analytics.com/mp/collect' +
'?measurement_id=' + MEASUREMENT_ID + '&api_secret=' + API_SECRET,
{
method: 'POST',
body: JSON.stringify({
client_id: clientId,
events: [{
name,
params: {
session_id: sessionId, // you keep this yourself
engagement_time_msec: 100, // or the event will not count as engaged
...params,
},
}],
}),
},
);
}And add Google to the manifest, or the request is blocked before it leaves Figma:
"networkAccess": {
"allowedDomains": ["https://www.google-analytics.com"]
}This does work. I want to be fair about that. It also has four problems that never go away.
- The API secret is in your bundle. Anyone who downloads the plugin can read it and send whatever they like into your property. Google's docs say to keep it private, and in a plugin there is no private.
- You are now the analytics SDK. Client id, session id, session timeout, engagement time: all yours to invent, store in
figma.clientStorageand keep consistent across versions. - A Measurement Protocol hit carries only what you put in it. The reports that make GA feel rich (country, device, realtime) are built from signals the browser sends with a normal page hit. You will fill in what you can and look at empty charts for the rest.
- Everything you get back is shaped like a website. Pages, sessions, bounce rate, landing pages. None of those words mean anything for a tool that lives in a side panel.
What you actually want to know
Strip the website vocabulary away and the questions a plugin developer has are short. How many people opened it this week. How many of them had opened it before. Which of the things it does do they use. How long a session takes. Where they are, roughly, so you know which language to add next. That is the whole list for most plugins, and none of it needs a cookie.
A plugin can answer all of it from inside the sandbox. figma.currentUser.id is a stable identity when your manifest asks for the currentuser permission. The session is simply the time the UI is open. Features are the function calls you already have. Country comes from a server-side lookup of the request IP, which you can do on your own backend or let a service do for you.
What I did instead
tode is that list, and nothing else. You call tode.init in the controller and trackAction where something happens. Sessions are measured for you over a WebSocket from the UI, users are counted by the Figma user id, country is derived on the server and the IP is thrown away. The dashboard has daily active users, new against returning, week-one retention, actions by name, metric distributions, sessions and a map.
If you already live in Google Analytics for a website and want everything under one roof, the Measurement Protocol is a real option, and I wrote up a side by side. If you want the numbers without building the plumbing, start with which events are worth sending.