Skip to content

Script reference

lytics.js is the CanaryLytics tracking snippet: a zero-dependency script (~2.5 KB min+gzip, budget-enforced in CI) that tracks pageviews, SPA navigations, and link clicks automatically, with no key on the page. It is SSR-safe: it bails out immediately if window/document aren’t available and does nothing until it runs in a real browser.

<script src="https://cdn.analytics.canarycoders.es/lytics.js" data-domain="myapp.com" defer></script>
Attribute Default Description
data-domain location.hostname Domain reported to /ingest. Must match one of the project’s trusted domains or events are silently dropped.
data-api https://api.analytics.canarycoders.es API origin override. Point this at a local or self-hosted instance.
data-hash absent (off) Opt-in flag (presence-only, no value needed). Includes location.hash in tracked paths and adds a hashchange pageview listener. See SPAs & client-side routing.
data-app absent Client tag, e.g. myapp-web. Set it when the same project also receives events from a native app, so the two are separable in the dashboard. Lowercase letters, digits, ., _ and -, up to 64 characters. See Native apps.

When the script is bundled into your own JS instead of loaded via a literal <script src> tag, document.currentScript is null and there is no element to read data-* attributes from. In that case, the same settings fall back to a pre-init global. Set it before the tracker script runs:

<script>
window.lytics = { domain: "myapp.com", api: "https://api.analytics.canarycoders.es", hash: true, app: "myapp-web" };
</script>
<script src="https://cdn.analytics.canarycoders.es/lytics.js" defer></script>

Precedence runs data attribute, then pre-init global, then built-in default. A data-domain attribute always wins over window.lytics.domain when both are present.

Every event the snippet sends is tagged platform: "web", which is what the dashboard’s Platform breakdown splits on. Add data-app on top of that when a project also collects from a native app.

The initial pageview fires once on load, with path = pathname + search (+ hash only when data-hash is set).

For SPA navigations the tracker patches history.pushState and replaceState and listens for popstate, so client-side route changes fire pageviews too. Consecutive calls to the same path are deduped.

Plain left-clicks on same-origin <a> elements fire a link_click event carrying the target path. Modified clicks, #/mailto:/tel:/javascript: hrefs and external links are left untracked.

Referrers are split by origin: a same-origin referrer contributes its path, a cross-origin one contributes only its hostname, never the path or query, so search terms never leave the browser. Both feed traffic-source classification and are then handled per the privacy stance.

Your own visits skew every stat, so the tracker can mark a browser as internal/team traffic:

window.lytics.internal(true); // this browser is team traffic from now on
window.lytics.internal(false); // back to a normal visitor

The flag persists in localStorage["cly_internal"] (per browser, across sessions, unlike the tab-scoped session id) and is read at send time, so flipping it mid-session flags every event from that moment on. To mark a browser before the tracker has loaded (or without it, e.g. from a bookmarklet), set the key directly: localStorage.setItem("cly_internal", "1").

Call it wherever your app knows the visitor is one of your own: after a staff login, on a QA device, behind an employee feature flag:

if (user.role === "admin") window.lytics.internal(true);

Internal events are still recorded, but every stats surface (dashboard, /v1/stats/*, SDK, MCP, widget) hides them unless you ask for them with includeInternal; the realtime visitor count excludes them always. The flag is self-declared by the browser, which is safe by construction: a stranger flipping it can only remove themselves from your stats, never add or distort anything.

lytics.js sends events via fetch with keepalive: true, credentials: "omit", and a text/plain body. That combination keeps it a CORS “simple request” (no preflight) and survives page unloads. credentials: "omit" matters: /ingest answers with a wildcard Access-Control-Allow-Origin: *, and browsers reject credentialed cross-origin requests against a wildcard ACAO. navigator.sendBeacon is used only as a fallback if fetch throws.

Every event is a POST /ingest with Content-Type: text/plain and a JSON body:

{
"domain": "myapp.com",
"sessionId": "<UUIDv4>",
"eventType": "page_view | link_click | custom",
"path": "/pricing",
"locale": "en-US",
"referrerHost": "t.co",
"referrerPath": "/old-page",
"targetPath": "/next-page",
"eventName": "signup",
"props": { "plan": "pro" },
"isInternal": true
}

locale, referrerHost (cross-origin hostname only), and referrerPath (same-origin SPA navigations) are optional; targetPath appears only on link_click; eventName and props (flat string/number/boolean values, ≤ 2 KB) only on custom; isInternal appears only while the internal-traffic flag is set. The response is always 204, because invalid or untrusted events are dropped silently by design (an error would be an oracle for attackers probing other projects). Debug your own setup with GET /v1/validate instead.

Each browser tab gets a random UUID (crypto.randomUUID(), with an RFC 4122 v4 fallback) stored in sessionStorage["cly_sid"]. It is tab-scoped: not shared across tabs, and not persisted once a tab closes. There is no cookie and no cross-session identifier. If sessionStorage or crypto throw (for example in a locked-down embed context), the tracker simply doesn’t send that event rather than falling back to something less private.