Core Events
Core events are a fixed set of UX signals the SDK can detect and emit on your behalf — no instrumentation code required. They land in your dashboard as regular custom events (same wire shape as Loguro.track()), so they show up in the Custom Events card and filter the same way.
All of them are opt-in. Default is off — set data-core-events to turn them on.
Enabling
Set data-core-events on the script tag. Either all for every signal, or a CSV of names:
<script
src="https://logu.ro/loguro.js"
data-api-key="brk_..."
data-analytics="all"
data-core-events="all"
></script> Granular:
<script
src="https://logu.ro/loguro.js"
data-api-key="brk_..."
data-analytics="all"
data-core-events="rage_click,dead_click,form_abandon"
></script> Important:
data-analyticsmust includeevents(orall). Core events go through the same batch pipeline asLoguro.track()— ifeventsis gated off, core events are silently dropped.
What ships
| Event | Fires when | Default props |
|---|---|---|
rage_click | 3 or more clicks on the same element within 500 ms | element, element_id, element_class, selector, clicks |
dead_click | Click on a non-form element with no DOM mutation, no fetch / XHR, and no navigation within 1 s after | element, element_id, element_class, selector, path |
form_abandon | User typed into a form field, then the page is hidden (close, navigate away) without that form’s submit firing | form_id, form_name, form_action, selector, fields_touched, duration_ms, path, reason (pagehide / visibilitychange) |
scroll_depth | First time the user crosses 25 / 50 / 75 / 100 % scroll on the current path. Once per milestone per pageview — SPA route change resets | milestone (25 / 50 / 75 / 100), path |
slow_route | Largest Contentful Paint > 4000 ms on the current path. Fires once per path | lcp_ms, path, element (LCP element tag) |
copy_event | copy event with selection length ≥ 10 characters | length, path |
external_link_click | Click on an <a> whose hostname differs from the current page | href, host, text (≤ 80 chars), selector, path |
download_click | Click on an <a> with a download attribute or a recognized file extension (pdf, zip, csv, xlsx, mp4, etc.) | href, extension, download_attr, text, path |
print_intent | beforeprint window event | path |
back_to_search | Two pageviews on different paths within 5 s — proxy for “wrong content, bounced back” | from_path, to_path, elapsed_ms |
Each one is a custom event in the wire payload:
{
"event_type": "custom",
"custom_event_name": "rage_click",
"url": "https://example.com/checkout",
"path": "/checkout",
"visitor_id": "01HZ...",
"session_id": "01HZ...",
"timestamp": "2026-05-17T20:55:11.564Z",
"custom_props": {
"custom_event_name": "rage_click",
"element": "BUTTON",
"element_id": "place-order",
"element_class": "btn btn-primary",
"selector": "button#place-order.btn",
"clicks": 3
}
} custom_event_name lives inside custom_props because that’s where the storage layer keeps it (Parquet schema is event_type + custom_props JSON — no separate name column). The top-level custom_event_name on the envelope is informational for downstream consumers; the in-props copy is the persisted form.
Heuristics & gotchas
rage_click
Detects 3 clicks on the same e.target within 500 ms. If a click on a different element falls in between, the counter for the original element resets — this is intentional (“rage at A” requires consecutive A clicks).
When triggered, the SDK also sends a warning log with source: "rage-clicks" — that’s existing behavior and stays on whenever data-capture includes rage-clicks (default), independent of data-core-events. The custom event is additive.
dead_click
Watches a 1 s window after each click. Anything counts as “alive”:
- Any DOM mutation under
<body>(childList, subtree, attributes, characterData) - Any
fetchorXHR.opencall kicked off - Pathname changed (navigation)
Clicks on <input> / <textarea> / <select> / <option> / <video> / <audio>, and anything inside <label>, are ignored — they’re inherently inert.
Caveat: needs to monkey-patch window.fetch and XMLHttpRequest.prototype.open to count network activity. If your app does the same and applies its patch after the SDK loads, the SDK still sees activity correctly (it wraps the original). Patches applied before the SDK loads are also fine. Bidirectional patching across multiple wrappers can theoretically double-count — in practice it’s harmless because dead-click only checks whether the counter increased, not by how much.
form_abandon
Tracked per <form> element via WeakMap — multiple forms on a page are tracked independently. “Touched” means at least one input event on a field that has .form === <this form> (excludes type="hidden", submit, button). Flush trigger is pagehide or visibilitychange === "hidden".
Caveat: SPA forms that submit via JS (event.preventDefault() + fetch) won’t fire the native submit event — they’ll look like abandons even though the user completed the flow. Use the path + selector payload to filter false positives, or call Loguro.track('form_submit', ...) manually instead.
scroll_depth
Computed as (scrollTop + viewportHeight) / totalDocumentHeight * 100. Throttled to requestAnimationFrame so it’s cheap even on long pages. If the page fits in the viewport (no scroll required), 100% fires immediately on mount — that’s correct: the user has seen everything.
State resets on path change so SPAs get one milestone-set per route.
slow_route
Piggybacks on the existing LCP PerformanceObserver (no extra observer). Fires only once per path — if you reload, you get one event; if the SPA renders the same path again later, no duplicate.
Threshold is hard-coded at 4000 ms. (Aligns with Web Vitals “poor” LCP cutoff.)
external_link_click and download_click
Both walk up the DOM tree from e.target to find the nearest <a> ancestor — so clicking a <span> inside a link still counts. URL is resolved against the current page so relative href works.
Download recognized extensions: pdf, zip, rar, 7z, tar, gz, bz2, exe, dmg, pkg, csv, xls, xlsx, doc, docx, ppt, pptx, odt, ods, odp, mp3, mp4, m4a, mov, avi, mkv, wav, webm, svg. Plus any <a download> regardless of extension.
If a download link is also external, both events fire (separate listeners).
back_to_search
Hooks the pageview module via an internal onPageview callback (no separate history patching — pageview.ts already handles that). On each pageview, compares to the last one: if path changed within 5 s, fires back_to_search with the previous path, the new path, and elapsed ms.
Won’t fire on the very first pageview (no baseline).
Common configurations
Just rage clicks (lowest noise, highest signal)
<script
src="https://logu.ro/loguro.js"
data-api-key="brk_..."
data-analytics="all"
data-core-events="rage_click"
></script> UX signals only, no analytics overhead from scroll/print
<script
src="https://logu.ro/loguro.js"
data-api-key="brk_..."
data-analytics="all"
data-core-events="rage_click,dead_click,form_abandon,slow_route"
></script> Everything — full UX telemetry
<script
src="https://logu.ro/loguro.js"
data-api-key="brk_..."
data-analytics="all"
data-core-events="all"
></script> Disabling / opting individual users out
Same as any other event: Loguro.setEnabled(false) stops all batching, including core events. See Privacy.
Querying in the dashboard
Core events appear in the Custom Events card on the web analytics dashboard, named exactly as listed in the table above. Filter the whole dashboard down by event name with the filter input:
custom_event_name:rage_click
custom_event_name:slow_route path:/checkout See Dashboard filters for the full filter syntax.
Next
- Custom Events — manual tracking with
Loguro.track() - Install & Configure — all
data-*attributes - Troubleshooting — why your events might not be landing