Skip to content

feat(recorder): support data-before-send for heatmap events - #4646

Open
RayanAlyasi wants to merge 2 commits into
umami-software:devfrom
RayanAlyasi:feat/recorder-before-send
Open

RayanAlyasi wants to merge 2 commits into
umami-software:devfrom
RayanAlyasi:feat/recorder-before-send

Conversation

@RayanAlyasi

@RayanAlyasi RayanAlyasi commented Oct 20, 2026 •

Copy link
Copy Markdown

Related to #4546

Problem

The tracker lets a site inspect, change or drop what it sends with data-before-send (or umamiConfig.beforeSend). The recorder has no equivalent, so heatmap events can't be adjusted:

  • Routes with IDs (/orders/HM547WwgG) each become their own heatmap page, and the raw path is stored even when the site normalizes it for page views.
  • On pages whose content scrolls inside a container, or that show several screens at one URL, clicks are filed under location.href with window-based coordinates, and the site can't correct them.

Change

  • The recorder resolves before-send the same way the tracker does: a data-before-send attribute naming a function on window, otherwise umamiConfig.beforeSend.
  • Each heatmap event (click or scroll) is passed to it as ('heatmap', event) before it is queued. heatmap matches the payload type sent to /api/record.
  • As in the tracker, the returned event is sent, a falsy value drops it, and a promise is awaited.
  • Without a hook nothing changes. Session replay events are not passed to the function.

A site that already sets umamiConfig.beforeSend for the tracker will also receive heatmap events in it, with type: 'heatmap'.

#4617 covers part of this with data-transform-url, which rewrites URLs in heatmap events and replay meta events. This PR follows the tracker's existing before-send API instead, as suggested on #4546, and also allows adjusting or dropping events.

Tests

Added src/recorder/index.test.ts, using the same setup as #4617's recorder tests:

  • events are sent unchanged without a hook
  • a data-before-send function receives ('heatmap', event) and its result is sent
  • a falsy result drops the event
  • umamiConfig.beforeSend is used, and a returned promise is awaited
  • data-before-send takes precedence over umamiConfig.beforeSend
  • replay events are not passed to the hook

The four before-send tests fail on dev and pass with this change.

  • pnpm test passes 1058 tests. src/app/mcp/route.test.ts fails, and pnpm lint reports 6 errors in files this PR doesn't touch; both are identical on dev without this change. Biome is clean on the changed files.
  • pnpm build:recorder grows recorder.js by 171 bytes.
  • Checked the built recorder.js in Chromium with real clicks against a stub /api/record: unchanged without a hook, URL rewritten with data-before-send, click dropped when the hook returns null, and an async umamiConfig.beforeSend result sent.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@vercel

vercel Bot commented Oct 20, 2026

Copy link
Copy Markdown

@RayanAlyasi is attempting to deploy a commit to the Umami Software Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps

greptile-apps Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The change affects heatmap collection for sites using the recorder.

Fix the final send for pending hook results before merging.

Findings

  1. P1 Last events go missing ▶
  2. P2 Events get the wrong time ▶

Summary

Adds beforeSend support for heatmap clicks and scroll events.

  • Sites can adjust or drop heatmap events before they are sent.

Reviews (1) · Last reviewed commit: "feat(recorder): support data-before-send..." · Reviewed by Greptile

Comment thread src/recorder/index.js
Comment on lines +324 to +326
Promise.resolve(callback('heatmap', event)).then(result => {
if (result) bufferHeatmapEvent(result);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Last events go missing

If a click's beforeSend hook is still pending when the page closes, flushHeatmap(true) runs before that click enters heatmapBuffer. When the hook resolves, this code only schedules the normal five-second send, which may never run before the page closes.

Buffer synchronous hook results immediately, and make results that finish after the final flush use an immediate keepalive send. Add a test that closes the page before a delayed hook resolves.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only happens with an async hook that is still pending when the page unloads. Synchronous hooks, and async functions that don't wait, are buffered before visibilitychange/beforeunload fire, so the final flush includes them. A pending promise doesn't resolve once the page is gone, so a later keepalive send wouldn't run either.

Checked in Chromium by clicking a link that navigates away: the click is sent with no hook, a synchronous hook and an async hook that doesn't wait; it's lost only when the hook waits (20 ms or more). If you'd prefer to avoid this case entirely, I can make the recorder's hook synchronous-only.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. A hook promise that is still pending when the document is unloaded cannot be relied on to resolve afterward, so scheduling a keepalive send from its callback would not make this case reliable.

Synchronous results—including async functions that resolve immediately—are buffered before the final flush, while a genuinely delayed promise is inherently best-effort during unload. I’m withdrawing this finding; I don’t think the recorder should make the hook synchronous-only when the tracker API already supports awaiting promises.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Comment thread src/recorder/index.js Outdated
}

Promise.resolve(callback('heatmap', event)).then(result => {
if (result) bufferHeatmapEvent(result);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Events get the wrong time

bufferHeatmapEvent assigns timestamp only after the async hook resolves, and the server stores that value as createdAt. A delayed hook therefore moves a click into a later time, potentially outside the report's selected date range.

Capture the event time before calling the hook and preserve it when buffering the result.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9eb0891. The time is now taken when the event is queued, before the hook runs, and kept whatever the hook returns. Added a test where the hook resolves later.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant