Merge pull request #50 from ciphera-net/staging

feat: track time-on-page via unload ping for accurate visit durations
This commit is contained in:
Usman
2026-03-14 14:26:39 +01:00
committed by GitHub
2 changed files with 30 additions and 9 deletions

View File

@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Improved
- **Visit duration now works for single-page sessions.** Previously, if a visitor viewed only one page and left, the visit duration showed as "0s" because there was no second pageview to measure against. Pulse now tracks how long you actually spent on the page and reports real durations — even for single-page visits. This makes the Visit Duration metric, Journeys, and Top Paths much more accurate.
- **Journeys page now shows data on low-traffic sites.** The Journeys page previously required at least 23 sessions following the same path before showing any data. It now shows all navigation flows immediately, so you can see how visitors move through your site from day one.
- **European date and time formatting.** All dates across Pulse now use day-first ordering (14 Mar 2025) and 24-hour time (14:30) instead of the US-style month-first format. This applies everywhere — dashboard charts, exports, billing dates, invoices, uptime checks, audit logs, and more.
### Added

View File

@@ -39,6 +39,9 @@
let clsObserved = false;
let performanceInsightsEnabled = false;
// * Time-on-page tracking: records when the current pageview started
var pageStartTime = 0;
// * Minimal Web Vitals Observer
function observeMetrics() {
try {
@@ -79,16 +82,30 @@
}
function sendMetrics() {
if (!performanceInsightsEnabled || !currentEventId) return;
if (!currentEventId) return;
// * Only include LCP/CLS when the browser actually reported them. Sending 0 overwrites
// * the DB before LCP/CLS have fired (they fire late). The backend does partial updates
// * and leaves unset fields unchanged.
const payload = { event_id: currentEventId, inp: metrics.inp };
if (lcpObserved) payload.lcp = metrics.lcp;
if (clsObserved) payload.cls = metrics.cls;
// * Calculate time-on-page in seconds (always sent, even without performance insights)
var durationSec = pageStartTime > 0 ? Math.round((Date.now() - pageStartTime) / 1000) : 0;
const data = JSON.stringify(payload);
var payload = { event_id: currentEventId };
// * Always include duration if we have a valid measurement
if (durationSec > 0) payload.duration = durationSec;
// * Only include Web Vitals when performance insights are enabled
if (performanceInsightsEnabled) {
payload.inp = metrics.inp;
// * Only include LCP/CLS when the browser actually reported them. Sending 0 overwrites
// * the DB before LCP/CLS have fired (they fire late). The backend does partial updates
// * and leaves unset fields unchanged.
if (lcpObserved) payload.lcp = metrics.lcp;
if (clsObserved) payload.cls = metrics.cls;
}
// * Skip if nothing to send (no duration and no vitals)
if (!payload.duration && !performanceInsightsEnabled) return;
var data = JSON.stringify(payload);
if (navigator.sendBeacon) {
navigator.sendBeacon(apiUrl + '/api/v1/metrics', new Blob([data], {type: 'application/json'}));
@@ -301,7 +318,7 @@
}
if (currentEventId) {
// * SPA nav: visibilitychange may not fire, so send previous page's metrics now
// * SPA nav: visibilitychange may not fire, so send previous page's metrics + duration now
sendMetrics();
}
@@ -309,6 +326,7 @@
lcpObserved = false;
clsObserved = false;
currentEventId = null;
pageStartTime = 0;
// * Only send external referrer on the first pageview (landing page).
// * SPA navigations keep document.referrer stale, so clear it after first hit
// * to avoid inflating traffic source attribution.
@@ -357,6 +375,7 @@
firstPageviewSent = true;
if (data && data.id) {
currentEventId = data.id;
pageStartTime = Date.now();
// * For SPA navigations the browser never emits a new largest-contentful-paint
// * (LCP is only for full document loads). After the new view has had time to
// * paint, we record time-from-route-change as an LCP proxy so /products etc.