Skip to content

Opt-in engaged time tracking for accurate visit duration - #4554

Open
anandghegde wants to merge 4 commits into
umami-software:devfrom
anandghegde:feat/engagement-heartbeat
Open

anandghegde wants to merge 4 commits into
umami-software:devfrom
anandghegde:feat/engagement-heartbeat

Conversation

@anandghegde

@anandghegde anandghegde commented Sep 19, 2026

Copy link
Copy Markdown

Refs #3518

What

Adds an opt-in tracker option, data-engagement="true", that measures engaged time. That is the time the page is visible and focused. Visit duration uses it in place of last-event-minus-first-event.

Right now a single-page visit always has a duration of 0. The last page of any visit also adds nothing to the duration. This PR fixes both for sites that turn the option on. Sites that don't turn it on are unchanged.

How

Tracker (src/tracker/index.ts, +407 B min / +123 B gzip)

  • Engaged time is added up on focus, blur and visibilitychange.
  • It is sent as { type: 'engagement', payload: { ...payload, engagement: ms } } on SPA route change (for the page being left), on visibilitychange and on pagehide. Anything under 1 s is not sent. It uses the existing send(), so before-send, the cache token and DNT behave as they do for other events.

Collection (/api/send)

  • New engagement type. engagement must be an integer from 1 to 86,400,000 ms.
  • The value is saved to the new website_engagement table with its session, visit and URL path.
  • Engagement pings skip the 30-minute visit rotation. The time belongs to the visit of the page being left, and would otherwise be saved under a new visit with no pageviews.

Storage

  • Postgres migration 27_add_website_engagement. ClickHouse migration 15_add_website_engagement plus schema.sql. The healthcheck in docker-compose.test.yml now waits on the new last table.
  • It is a separate table, not a new event_type on website_event. That keeps pageview counts, website_event_stats_hourly and the existing NOT IN (2, 5) filters as they are.
  • Rows are deleted with the website or session (Postgres), the same as heatmap_event.

Queries

  • getEngagementQuery() in lib/prisma and lib/clickhouse joins the engaged seconds for each visit.
  • A visit's duration is coalesce(engaged, max - min). This is used in getWebsiteStats, getSessionExpandedMetrics, getChannelExpandedMetrics and getWebsiteSession.
  • getBreakdown and getEventExpandedMetrics are unchanged. They group below visit level, so a per-visit total would be counted more than once.

Testing

  • Unit tests cover the tracker (visible and focused time only, one send per page), the collect route (save, validation, a visit older than 30 minutes) and the query builders.
  • Postgres and ClickHouse 25.8 in Docker, with a test page and headless Chromium. Scenario: 4 s on a page, SPA navigation, 3 s on the next page, then leave.
    • Two engagement rows (≈4000 and ≈3000 ms), same visit. Visit duration 7 s on stats, session stats, the browser and channel tables, and session details, on both backends.
    • A control site without the option gave 4–5 s, which is the first-to-last event span.
  • Headed Chromium, window minimized for about 6 s. Only the focused time was counted.
  • pnpm build passes and the migration applies. Biome and vitest show no new failures.

Notes / open questions

  • The Pages table has no time column yet. url_path is stored so time on page could be added later.
  • With a path filter, visit duration is the whole visit's engaged time. Today it is the event span of the matching events, which is 0 for a single page.
  • There is no idle detection. A focused tab that is left open keeps counting, which is also how Plausible works.
  • The option needs a docs entry in umami-software/website.

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

Add a data-engagement="true" tracker option that measures time the page
is visible and focused, and sends it on route change, visibilitychange
and pagehide as a new "engagement" collection type.

Engaged time is stored per page in a new website_engagement table
(Postgres and ClickHouse). Visit duration uses the visit's engaged time
when it exists and falls back to the first-to-last event span otherwise,
so sites that do not enable the option are unchanged.

Refs umami-software#3518
@vercel

vercel Bot commented Sep 19, 2026

Copy link
Copy Markdown

@anandghegde 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 Sep 19, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 3/5

The PR is not safe to merge until sub-second engagement survives flushes and cross-boundary visits retain their duration.

Findings

  1. P1 Subsecond engagement is discarded
  2. P1 Boundary visits lose engagement
  3. P2 Engagement value remains optional

Summary

This PR adds opt-in focused-and-visible engagement tracking, persists engagement in dedicated PostgreSQL and ClickHouse tables, and uses the resulting per-visit totals in duration metrics. The storage and dual-backend query paths are broadly aligned, but the client currently discards sub-second intervals and report-boundary visits can lose their engagement entirely. The collection schema also accepts engagement requests without a duration.

Diagram
sequenceDiagram
  participant B as Browser tracker
  participant A as /api/send
  participant E as website_engagement
  participant Q as Duration queries

  B->>B: Accumulate visible and focused time
  B->>A: engagement payload on navigation/hide
  A->>E: Store session, visit, path, duration
  Q->>E: Sum engagement by session and visit
  Q->>Q: Prefer engagement over event span
Loading

Reviews (1) · Last reviewed commit: "feat: opt-in engaged time tracking for a..."

Comment thread src/tracker/index.ts Outdated
Comment thread src/lib/prisma.ts Outdated
Comment thread src/app/api/send/route.ts
Intervals shorter than a second were reset on every flush, so quick
route changes or tab switches lost that time from the visit total.
Keep the remainder and send it with the next report instead.
Engagement is written when the page is left, which can be after the
range that contains the visit's events. Allow up to a day past endDate
(the largest single report) when joining engagement to visits.

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