> ## Content Index
> Fetch the complete content index at: https://anantafatur.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Umami recorder.js not ingesting data on Ghost CMS
- URL: https://anantafatur.dev/umami-recorder-heatmap-debug/
- Published: 2026-08-15T18:52:25.000Z
- Updated: 2026-08-15T18:52:25.000Z
- Description: 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.
- Author: Ananta

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:

```html
<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:

```json
{
  "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:

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

```

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

```javascript
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:

```javascript
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`:

```javascript
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:

```html
<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:

```javascript
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.