diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36f9ab0..a8d4725 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **AI traffic source identification.** Pulse now automatically recognizes visitors coming from AI tools — ChatGPT, Perplexity, Claude, Gemini, Copilot, DeepSeek, Grok, Meta AI, You.com, and Phind. These sources appear in your Top Referrers with proper brand icons and display names instead of raw domain URLs. If someone clicks a link in an AI chat to visit your site, you'll see exactly which AI tool sent them.
- **Automatic outbound link tracking.** Pulse now tracks when visitors click links that take them to other websites. These show up as "outbound link" events in your Goals & Events panel — no setup needed. You can turn this off in your tracking snippet settings if you prefer.
- **Automatic file download tracking.** When a visitor clicks a link to a downloadable file — PDF, ZIP, Excel, Word, MP3, and 20+ other formats — Pulse records it as a "file download" event. Like outbound links, this works automatically with no setup required.
+- **Automatic 404 error page detection.** Pulse now detects when a visitor lands on a page that doesn't exist and records it as a "404" event. You'll see these in your Goals & Events panel so you can find and fix broken links. Works automatically — no setup needed.
+- **Automatic scroll depth tracking.** Pulse now tracks how far visitors scroll down each page — at 25%, 50%, 75%, and 100% milestones. These show up as scroll events in your Goals & Events panel, helping you understand which content keeps people reading. Works automatically with no configuration required.
### Improved
diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx
index 31ace64..8651173 100644
--- a/app/sites/[id]/page.tsx
+++ b/app/sites/[id]/page.tsx
@@ -19,6 +19,7 @@ import TechSpecs from '@/components/dashboard/TechSpecs'
import Chart from '@/components/dashboard/Chart'
import PerformanceStats from '@/components/dashboard/PerformanceStats'
import GoalStats from '@/components/dashboard/GoalStats'
+import ScrollDepth from '@/components/dashboard/ScrollDepth'
import Campaigns from '@/components/dashboard/Campaigns'
import {
useDashboardOverview,
@@ -380,8 +381,9 @@ export default function SiteDashboardPage() {
-
-
+
+ !/^scroll_\d+$/.test(g.event_name))} />
+
()
+ for (const row of goalCounts) {
+ const match = row.event_name.match(/^scroll_(\d+)$/)
+ if (match) {
+ scrollCounts.set(Number(match[1]), row.count)
+ }
+ }
+
+ const hasData = scrollCounts.size > 0 && totalPageviews > 0
+
+ return (
+
+
+
+ Scroll Depth
+
+
+
+ {hasData ? (
+
+ {THRESHOLDS.map((threshold) => {
+ const count = scrollCounts.get(threshold) ?? 0
+ const pct = totalPageviews > 0 ? Math.round((count / totalPageviews) * 100) : 0
+ const barWidth = Math.max(pct, 2)
+
+ return (
+
+
+
+ {threshold}%
+
+
+
+ {formatNumber(count)}
+
+
+ {pct}%
+
+
+
+
+
+ )
+ })}
+
+ ) : (
+
+
+
+
+
+ No scroll data yet
+
+
+ Scroll depth tracking is automatic — data will appear here once visitors start scrolling on your pages.
+
+
+ )}
+
+ )
+}
diff --git a/public/script.js b/public/script.js
index 990e0db..ee704b6 100644
--- a/public/script.js
+++ b/public/script.js
@@ -299,6 +299,10 @@
if (url !== lastUrl) {
lastUrl = url;
trackPageview();
+ // * Check for 404 after SPA navigation (deferred so title updates first)
+ setTimeout(check404, 100);
+ // * Reset scroll depth tracking for the new page
+ if (trackScroll) scrollFired = {};
}
}
new MutationObserver(onUrlChange).observe(document, { subtree: true, childList: true });
@@ -308,7 +312,11 @@
history.replaceState = function() { _replace.apply(this, arguments); onUrlChange(); };
// * Track popstate (browser back/forward)
- window.addEventListener('popstate', trackPageview);
+ window.addEventListener('popstate', function() {
+ trackPageview();
+ setTimeout(check404, 100);
+ if (trackScroll) scrollFired = {};
+ });
// * Custom events / goals: validate event name (letters, numbers, underscores only; max 64 chars)
var EVENT_NAME_MAX = 64;
@@ -346,6 +354,62 @@
window.pulse = window.pulse || {};
window.pulse.track = trackCustomEvent;
+ // * Auto-track 404 error pages (on by default)
+ // * Detects pages where document.title contains "404" or "not found"
+ // * Opt-out: add data-no-404 to the script tag
+ var track404 = !script.hasAttribute('data-no-404');
+ var sent404ForUrl = '';
+
+ function check404() {
+ if (!track404) return;
+ // * Only fire once per URL
+ var currentUrl = location.href;
+ if (sent404ForUrl === currentUrl) return;
+ if (/404|not found/i.test(document.title)) {
+ sent404ForUrl = currentUrl;
+ trackCustomEvent('404');
+ }
+ }
+
+ // * Check on initial load (deferred so SPAs can set title)
+ setTimeout(check404, 0);
+
+ // * Auto-track scroll depth at 25%, 50%, 75%, and 100% (on by default)
+ // * Each threshold fires once per pageview; resets on SPA navigation
+ // * Opt-out: add data-no-scroll to the script tag
+ var trackScroll = !script.hasAttribute('data-no-scroll');
+
+ if (trackScroll) {
+ var scrollThresholds = [25, 50, 75, 100];
+ var scrollFired = {};
+ var scrollTicking = false;
+
+ function checkScroll() {
+ var docHeight = document.documentElement.scrollHeight;
+ var viewHeight = window.innerHeight;
+ // * Page fits in viewport — nothing to scroll
+ if (docHeight <= viewHeight) return;
+ var scrollTop = window.scrollY;
+ var scrollPercent = Math.round((scrollTop + viewHeight) / docHeight * 100);
+
+ for (var i = 0; i < scrollThresholds.length; i++) {
+ var t = scrollThresholds[i];
+ if (!scrollFired[t] && scrollPercent >= t) {
+ scrollFired[t] = true;
+ trackCustomEvent('scroll_' + t);
+ }
+ }
+ scrollTicking = false;
+ }
+
+ window.addEventListener('scroll', function() {
+ if (!scrollTicking) {
+ scrollTicking = true;
+ requestAnimationFrame(checkScroll);
+ }
+ }, { passive: true });
+ }
+
// * Auto-track outbound link clicks and file downloads (on by default)
// * Opt-out: add data-no-outbound or data-no-downloads to the script tag
var trackOutbound = !script.hasAttribute('data-no-outbound');