---
title: "Script reference"
description: "Every configuration attribute lytics.js reads, what it tracks automatically, its wire format, and how the keyless snippet transports events to /ingest."
last_updated: "2026-07-30"
---

# 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.

```html
<script src="https://cdn.analytics.canarycoders.es/lytics.js" data-domain="myapp.com" defer></script>
```

## Attributes

| 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](/docs/environments/spas/#hash-router-spas). |

## Programmatic config fallback

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 three settings fall back to a pre-init global. Set it **before** the tracker script runs:

```html
<script>
  window.lytics = { domain: "myapp.com", api: "https://api.analytics.canarycoders.es", hash: true };
</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.

## What automatic tracking covers

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](/docs/#privacy-stance).

<Aside type="tip">
  Need custom events, goal conversions, or UTM capture? See [Custom events, goals & UTM tracking](/docs/tracking/events-and-goals/).
</Aside>

## Transport

`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.

<Aside type="note">
  A keepalive request fired during navigation can show a duplicate `ERR_ABORTED` row in Chrome DevTools. That is a DevTools display artifact. The event landed once, which you can verify in the dashboard or via `/v1/stats`.
</Aside>

## Wire format

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

```json
{
  "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" }
}
```

`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`. 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`](/docs/troubleshooting/) instead.

## Session id

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.

## Related

<CardGrid>
  <LinkCard title="Custom events & goals" description="window.lytics.track(), the early-call queue, and goals." href="/docs/tracking/events-and-goals/" />
  <LinkCard title="Troubleshooting" description="Events not showing up? Start with /v1/validate." href="/docs/troubleshooting/" />
</CardGrid>

## Sitemap

- [Docs sitemap](https://analytics.canarycoders.es/docs/sitemap.md)
- [Full documentation as a single file](https://analytics.canarycoders.es/llms-full.txt)
