// context projectglobal

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-analytics must include events (or all). Core events go through the same batch pipeline as Loguro.track() — if events is gated off, core events are silently dropped.

What ships

EventFires whenDefault props
rage_click3 or more clicks on the same element within 500 mselement, element_id, element_class, selector, clicks
dead_clickClick on a non-form element with no DOM mutation, no fetch / XHR, and no navigation within 1 s afterelement, element_id, element_class, selector, path
form_abandonUser typed into a form field, then the page is hidden (close, navigate away) without that form’s submit firingform_id, form_name, form_action, selector, fields_touched, duration_ms, path, reason (pagehide / visibilitychange)
scroll_depthFirst time the user crosses 25 / 50 / 75 / 100 % scroll on the current path. Once per milestone per pageview — SPA route change resetsmilestone (25 / 50 / 75 / 100), path
slow_routeLargest Contentful Paint > 4000 ms on the current path. Fires once per pathlcp_ms, path, element (LCP element tag)
copy_eventcopy event with selection length ≥ 10 characterslength, path
external_link_clickClick on an <a> whose hostname differs from the current pagehref, host, text (≤ 80 chars), selector, path
download_clickClick 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_intentbeforeprint window eventpath
back_to_searchTwo 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 fetch or XHR.open call 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

// related

See also