DEV Community

Cover image for Why Google Analytics breaks inside a Figma plugin, and what I built instead
Ilia Kolebaev
Ilia Kolebaev

Posted on Originally published at usetode.dev

Why Google Analytics breaks inside a Figma plugin, and what I built instead

I make Figma plugins. The numbers Figma gives me about them are installs and likes on the Community page. Neither one says whether anybody opened the plugin this week, or which of its buttons they pressed.

The usual answer on the web is Google Analytics. Paste the gtag snippet into a plugin's ui.html, open the console, and you get this:

SecurityError: Failed to read the 'cookie' property from 'Document':
Cookies are disabled inside 'data:' URLs.
Enter fullscreen mode Exit fullscreen mode

The snippet is fine. The place it runs in is the problem, and that place turned out to be interesting enough that I ended up writing my own SDK for it. This post is about what I ran into.

A plugin is two programs

The first program is the main code, usually code.ts. It runs in a sandbox next to the document. It has the figma API and fetch. It has no DOM, no window and no document.

The second program is the UI. You pass an HTML string to figma.showUI(), and Figma loads that string into an iframe. The page gets a data: URL, so its origin is null.

Cookies, localStorage and IndexedDB all belong to an origin. A null origin owns none of them, and reading any of them throws. That is the error above.

The two programs cannot call each other. They talk through postMessage, and that is the only channel.

So the half that knows who the user is (the sandbox, through figma.currentUser) cannot see a click. The half that sees every click (the iframe) cannot remember anything and does not know who is clicking.

Web analytics assumes one page with a cookie jar. I had two halves and no jar.

Problem 1: the key is on one side, the buttons on the other

I wanted the developer to write the API key once. The sandbox is the right place for it, because the Figma data is there too:

// code.ts
import { tode } from '@tode-sdk/core';

figma.showUI(__html__);
await tode.init({ apiKey: 'your-api-key' });
Enter fullscreen mode Exit fullscreen mode

init() reads the user id, the plugin id, the editor type (Figma, FigJam, Dev Mode, Slides) and posts all of it to the iframe in one message:

figma.ui.postMessage({ type: 'TODE_DATA', message: config });
Enter fullscreen mode Exit fullscreen mode

The UI adapter listens for that message and configures itself. After that, both halves can send events with the same identity.

That is the happy path. It has a hole in it.

Problem 2: the first message gets lost

figma.showUI() returns before the iframe has loaded anything. If init() is fast and the UI bundle is slow, the config is posted to a page with no listener yet. Nobody hears it, the UI never becomes ready, and no error appears anywhere.

The same thing happens when a plugin closes its UI and calls showUI() again later. The new iframe starts empty.

The fix is a handshake that starts from the UI. When an adapter attaches its listener, it asks:

window.parent.postMessage(
  { pluginMessage: { type: 'TODE_UI_READY' }, pluginId: '*' },
  '*',
);
Enter fullscreen mode Exit fullscreen mode

The sandbox answers by posting the config again. One detail matters here. Most plugins already set figma.ui.onmessage for their own messages, and assigning it a second time would overwrite their handler. figma.ui.on('message', ...) adds a listener next to it and leaves theirs alone, so the SDK uses that.

The plugin's own handler still receives the TODE_UI_READY message. It has to ignore message types it does not know, which a well-written handler does anyway.

Problem 3: events that fire before the SDK is ready

A common first event is "plugin opened". It fires on mount, before the config has crossed over from the sandbox.

My first version told developers to wrap every call in if (isReady). That is a rule people forget, and the event they lose is the most important one.

Now the SDK keeps early events in a queue with the time they happened, and sends them when the config arrives:

if (this._isReady && this._apiKey) {
  await this.send(event);
  return;
}
if (this._queue.length >= MAX_QUEUED_EVENTS) return; // 50
this._queue.push(event); // event.timestamp was set at call time
Enter fullscreen mode Exit fullscreen mode

The cap is 50. If something calls trackAction in a loop while the SDK is not ready, I would rather drop events than grow an array forever inside someone else's plugin.

Problem 4: how long is a session

On a website, session length comes from page views with timestamps and a timeout. A plugin has no pages. It has a panel that is open for a while and then is gone.

I did not want to depend on an unload event firing in an iframe that Figma can remove at any moment. So I turned the question around. The UI opens a WebSocket when it becomes ready and holds it. The server writes down when the socket opened. When the socket closes, for any reason, the server writes one session row with the duration.

A closed panel, a closed tab and a crashed browser all look the same to the server: the socket is gone. No goodbye message is needed.

The client sends ping every 5 seconds. If the server hears nothing for 60 seconds, it ends the session itself, so a dead connection cannot inflate the numbers.

That covers a panel someone opens and closes. Three other cases needed more work.

The first is laptop sleep. People leave a plugin open, close the lid and come back hours later. The socket is dead and the panel is still on screen. The SDK reconnects with backoff (1s, 2s, 4s, capped at 30s) and never gives up while the UI is open. It also listens for the browser's online event and retries at once when the network is back.

The second follows from the first. Each reconnect is a new session row on the server. Every connection carries the same session_id, generated once per UI load, so the rows can be grouped later.

The third is React. Five components calling useTode() used to mean five sockets and five sessions for one person. The socket is now shared and reference-counted. The first caller opens it, and it closes when the last holder is gone.

Problem 5: analytics must never break the plugin

If my server is down, a designer's export button still has to work. So nothing in the SDK throws. init() and trackAction() catch everything and log to the console.

The sandbox made this harder than I expected. It has fetch, and it has no AbortController. Its fetch ignores signal. A request that hangs cannot be cancelled there, which means await tode.init() could block a plugin's startup.

The workaround is a race against a timer:

if (typeof AbortController === 'undefined') {
  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(() => reject(new Error('Timed out')), 10_000);
  });
  return Promise.race([fetch(url, rest), timeout])
    .finally(() => clearTimeout(timer));
}
Enter fullscreen mode Exit fullscreen mode

The request itself may still be hanging somewhere. The plugin has stopped waiting for it, and that is the part I care about.

Counting people without a cookie

A cookie exists on the web because the page does not know who you are. A plugin does. figma.currentUser.id is stable for a Figma account, and you get it by adding one line to the manifest:

{
  "permissions": ["currentuser"],
  "networkAccess": {
    "allowedDomains": [
      "https://processing.usetode.dev",
      "wss://processing.usetode.dev"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

That id is enough to tell a new user from a returning one, which is where retention comes from. The SDK sends the id and nothing else about the person: no name, no email, no file contents.

Country is looked up on the server from the request IP. The IP is not stored.

Without the currentuser permission, figma.currentUser is null. The SDK sends an empty value in that case and events still arrive. You lose unique users and keep everything else.

What it looks like from the plugin side

After all of that, the part a plugin developer writes is small:

import { useTode } from '@tode-sdk/figma-react';

function ExportButton() {
  const { trackAction, trackActionWithMetric } = useTode();

  const onExport = async () => {
    const layers = await exportSelection();
    trackAction('export_clicked');
    trackActionWithMetric('layers_exported', layers.length);
  };

  return <button onClick={onExport}>Export</button>;
}
Enter fullscreen mode Exit fullscreen mode

There are adapters for React, Vue, Angular, Svelte and plain TypeScript. They are thin. The queue, the handshake and the socket all live in the core package, so I only fix each bug once.

What I have not solved

There is no way to learn from plugin code that an event failed to deliver. Failures go to console.error and that is all. I chose silence over callbacks because I did not want analytics error handling in people's plugins, and I am still not sure it was the right call.

Sessions need a UI. A plugin that runs a command with no panel has no iframe to hold a socket, so it gets events and no session length.

And a reconnect still shows up as a separate row on the server. The shared session_id makes it fixable, and I have not built the merge yet.

If you want to look at the code

The SDK is MIT licensed and lives at github.com/kolebayev/tode-sdk. The service behind it is tode. It has a free plan for one plugin, and the paid plans are how I cover the servers.

If you came here for the Google Analytics error and want the long answer, I wrote up what the Measurement Protocol can and cannot do inside a plugin.

I would like to hear from people who build extensions for other hosts, like VS Code, Chrome or Sketch. How do you find out whether anyone uses what you shipped?

Top comments (1)

Collapse
 
omyvnss profile image
Om Yaduvanshi •

the queue detail is my favourite part. most sdks make the developer remember isready, which means the most important event is the one that gets dropped. a bounded queue with timestamps inverts that: the sdk absorbs the timing problem instead of exporting it. 50 as the cap is a nice touch, unbounded would be a memory leak wearing a trench coat.