Umami recorder.js not ingesting data on Ghost CMS

I debugged why Umami's recorder.js and heatmap tracker were not ingesting data on my Ghost blog. The root cause was a missing getSession method in the tracker script shipped with Umami v3.3.0, running alongside Ghost 6.57.1.

Umami recorder.js not ingesting data on Ghost CMS
Photo by Luke Chesser / Unsplash

My blog runs on Ghost 6.57.1 (alpine3.23) and I use a self-hosted Umami v3.3.0 instance at analytics.anantafatur.dev for web analytics. The normal tracker worked fine. Pageviews, custom events, all good. But when I tried to enable replays and heatmaps, nothing showed up. The recorder script loaded with a 200 OK, but the dashboard stayed empty.

The setup

Both scripts are loaded through Ghost's Code Injector footer. I had them like this:

<script defer src="https://analytics.anantafatur.dev/script.js"
        data-website-id="fdba9eea-5a40-4a24-927f-4d5672a9b7c8">
</script>

<script defer src="https://analytics.anantafatur.dev/recorder.js"
        data-website-id="fdba9eea-5a40-4a24-927f-4d5672a9b7c8">
</script>

I also had the Replays and Heatmaps toggles turned on in the Umami admin panel. I double checked the API endpoint to confirm:

{
  "enabled": true,
  "replayEnabled": true,
  "heatmapEnabled": true,
  "sampleRate": 1,
  "heatmapSampleRate": 1
}

Everything looked right. But no data.


What I ruled out early

First thing I checked was the script loading order. I thought maybe recorder.js needed to load before script.js to hook into the tracker. I tried swapping them. Did not help. The original order was correct: the recorder needs window.umami to exist first, so script.js should come before recorder.js.

Next, I checked the Content Security Policy. My site is behind Cloudflare, and I have a CSP rule in place. Looking at the browser console, I saw two things missing.

The rrweb library bundled inside recorder.js needs two things my CSP was not allowing. First, it uses eval() internally for DOM serialization, so script-src needed 'unsafe-eval'. Second, it spawns blob Web Workers for canvas recording, which meant I needed a new worker-src directive. I added both.

Final CSP:

default-src 'self' https: data: blob:; script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://analytics.anantafatur.dev https://static.cloudflareinsights.com https://challenges.cloudflare.com 'unsafe-inline' 'unsafe-eval'; style-src 'self' https: 'unsafe-inline'; img-src 'self' https: data: blob:; connect-src 'self' https://analytics.anantafatur.dev https://static.cloudflareinsights.com https://cdn.jsdelivr.net https://api.unsplash.com https://ghost.org https://challenges.cloudflare.com; frame-ancestors 'self' https://analytics.anantafatur.dev; worker-src 'self' blob:

There was also a Content-Security-Policy-Report-Only header with connect-src 'none' showing up in the console. It was report-only, so it was not actually blocking anything. I ignored it.

CSP fixed. Still no data.


The investigation

At this point I was stuck. The recorder loaded fine, the API config was correct, the CSP was fixed. But the dashboard stayed empty. I needed to understand what the recorder was actually doing internally, and honestly, I was not about to read thousands of lines of minified JavaScript by myself.

So I asked DeepSeek-v4-pro (the AI coding agent in my Zed editor) to help. It fetched both scripts from my Umami server, read through the minified source, and pointed out the critical piece. The recorder has a function that gates all data sending:

const P = () => e.umami?.getSession?.()?.cache;

And the function that sends data to the server checks this gate:

const W = (e, t, r) => {
    const s = P();
    if (!s) return;  // silently drops everything
    // ... fetch to /api/record
};

If getSession().cache is falsy, the recorder never sends a single byte. It also has a startup retry loop that checks the same gate every 100ms for 5 seconds. If the gate stays closed, it gives up silently.

So I opened Chrome DevTools and checked what my tracker actually exposes:

window.umami
// { track: f, identify: f }

window.umami?.getSession?.()
// undefined

There it was. The tracker only exposes track and identify. No getSession method. The recorder expects it, the tracker does not provide it, and the whole thing fails silently.

The root cause

DeepSeek-v4-pro also dug into the tracker source code. Here is how it sets up window.umami:

t.umami = { track: J, identify: P };

That is it. Two methods. The session cache exists as a private variable R inside the tracker's closure, but it is never exposed. The recorder, on the other hand, was written expecting getSession() to return that cache.

Both scripts are served from the same Umami v3.3.0 Docker image. This is a version mismatch bug inside the same release. The tracker and recorder shipped together but are incompatible with each other.

The fix

I could not modify the Umami source directly. It is served from the Docker container. But DeepSeek-v4-pro suggested a polyfill approach: add a script in my Ghost Code Injector footer that runs before the tracker scripts.

The idea is simple. Use Object.defineProperty to intercept the window.umami assignment. When the tracker sets window.umami, inject getSession into it. The cache value needs to be the real session cache from the tracker's API responses, so the polyfill also monkey-patches fetch to capture it.

DeepSeek-v4-pro wrote the polyfill and I dropped it into my Code Injector footer:

<script>
  (function () {
    var _cache = '';
    var _origFetch = window.fetch;
    window.fetch = function (url, opts) {
      var result = _origFetch.call(this, url, opts);
      if (typeof url === 'string' && url.indexOf('/api/send') !== -1) {
        result.then(function (r) {
          r.clone().json().then(function (d) {
            if (d && d.cache) _cache = d.cache;
          }).catch(function () {});
        }).catch(function () {});
      }
      return result;
    };
    var _umami;
    Object.defineProperty(window, 'umami', {
      get: function () { return _umami; },
      set: function (val) {
        _umami = val;
        if (val && !val.getSession) {
          val.getSession = function () {
            return { cache: _cache || '1' };
          };
        }
      },
      configurable: true,
      enumerable: true,
    });
  })();
</script>

Three things happen here. First, I patch fetch to intercept responses from /api/send and extract the real session cache. The tracker sends its first request, the server responds with a JWT cache, and my patch captures it. Second, I intercept the window.umami assignment. When the tracker sets it, I add getSession() that returns the captured cache. Third, I use '1' as a fallback for the initial request. The recorder checks getSession().cache for truthiness, and '1' is truthy. An empty string would not work because the recorder's gate is if (!s) return.

The fallback is a bit hacky. The real fix would be for Umami to expose the method natively. But this polyfill gets the recorder running.

Verification

After deploying the polyfill, I checked the console:

window.umami.getSession()
// { cache: 'eyJhbGciOiJIUzI1NiIs...', website: 'fdba9eea-5a40-4a24-927f-4d5672a9b7c8' }

The method exists. The cache is a real JWT. The recorder's gate opens. I browsed a few pages, waited a minute, and checked the Umami dashboard. Replays and heatmaps were both ingesting data.


Closing

The real mistake was assuming that if a script loads with 200 OK, it is working. The recorder fails silently. There is no console error, no network error, no warning. It just does nothing. I spent time on CSP and script ordering because those are the visible problems. The actual bug was hidden inside the minified source code.

Having an AI coding agent that can fetch URLs, read source code, and trace execution paths saved me hours of tedious debugging. The CSP and script ordering fixes were things I could find on my own. But the real bug? That needed a second pair of eyes that was willing to read minified source.