# juuna integration guide

juuna has three parts: a browser SDK (`@muziris/juuna`), an HTTP ingestion
API, and a dashboard. This page is everything you need to send events. A
[plain-markdown copy](https://juuna.app/docs/llms.txt) is served for AI agents.

Every event belongs to a **source**, one per product or site. Each source has a
public **write key** (`wk_…`) that identifies it on the wire; an admin creates
one under **Settings → Sources**. The key is public by design, since it ships to
every browser, but anyone holding it can write events to that source. Use one
key per product, and set that source's allowed domains.

## Quickstart: script tag (any site)

```html
<script defer src="https://juuna.app/sdk/juuna.js"
        data-write-key="wk_YOUR_WRITE_KEY"
        data-api-host="https://juuna.app"></script>
```

That is a complete integration. The SDK initialises itself from the data
attributes, records the pageview, and builds the visitor and pageview model you
see in the dashboard. Three optional attributes switch on the rest of
autocapture: `data-web-vitals="true"` (Core Web Vitals),
`data-click-tracking="true"` (click heatmaps) and
`data-scroll-tracking="true"` (scroll and attention maps). Single-page apps
call `juuna.page()` on route changes; everything else is captured
automatically. To send custom events, identify users, or tune batching, drive
the SDK explicitly instead:

```html
<script defer src="https://juuna.app/sdk/juuna.js"></script>
<script>
  window.addEventListener('DOMContentLoaded', function () {
    juuna.init('wk_YOUR_WRITE_KEY', {
      apiHost: 'https://juuna.app',
      clickTracking: true,   // click heatmaps
      scrollTracking: true,  // scroll-depth + attention maps
      webVitals: true,       // real-user performance (LCP/CLS/INP/FCP/TTFB)
    })
    juuna.page()
    juuna.track('Signup Completed', { plan: 'pro' })
  })
</script>
```

The script build is 1.0 KB gzipped, has no dependencies, and never throws into
the host page. Click, scroll and Web Vitals capture live in a small extras file
(`juuna-x.js`, 1.9 KB gzipped) that the tag loads from its own directory only
when you switch one of them on, so a self-hosted copy needs both files side by
side. It has one limitation: **session replay is not available from the script
build**. Replay requires the ESM package below.

## Quickstart: npm (bundled apps)

The package is on the public npm registry. No token, no registry configuration:

```bash
npm install @muziris/juuna     # or pnpm add / yarn add
```

```ts
import { juuna } from '@muziris/juuna'

juuna.init('wk_YOUR_WRITE_KEY', {
  apiHost: 'https://juuna.app',
  clickTracking: true,
  scrollTracking: true,
  webVitals: true,
  sessionReplay: true,          // ESM-only; rrweb lazy-loads as its own chunk
})
juuna.page()
```

## SDK reference

### `juuna.init(writeKey, options?)`

Must be called before anything else. Calls made before `init` are dropped
silently; nothing is ever thrown into your app. Options:

| Option | Default | What it does |
| --- | --- | --- |
| `apiHost` | none | Base URL of this juuna instance, no trailing slash. Set it to `https://juuna.app`. |
| `flushAt` | `20` | Send the queued batch once this many events are buffered. |
| `flushInterval` | `5000` | Also flush at most this often (ms) while events trickle in. |
| `clickTracking` | `false` | Autocapture click positions as `$click` events (normalised page coordinates only, never element contents). Powers the click heatmaps. |
| `scrollTracking` | `false` | Autocapture per-pageview scroll behaviour as one `$scroll` event (max depth + coarse per-band dwell). Powers the scroll/attention maps. |
| `webVitals` | `false` | Measure LCP, CLS, INP, FCP and TTFB per page load with the browser's PerformanceObserver and emit one `$vital` event when the page is first hidden. Powers the Vitals tab. |
| `sessionReplay` | `false` | Record the session with rrweb (ESM build only; lazy-loaded chunk). |
| `sessionReplaySampleRate` | `1` | Fraction of sessions to record, in [0, 1]. Sticky per tab. |
| `sessionReplayFlushIntervalMs` | `2000` | How often (ms) buffered replay events ship to the server, clamped to [500, 10000]. Sets the floor of live-view latency. |
| `sessionReplayMaxMinutes` | `30` | Hard cap on one recording's length, in minutes, clamped to [1, 240]. The server enforces the same limit. |
| `sessionReplayMaskAllInputs` | `true` | Mask every input value in recordings. Passwords are always masked regardless. |
| `sessionReplayMaskAllText` | `false` | Also mask all visible text. |

### Methods

```ts
juuna.page(name?, properties?)             // record a pageview (URL/path/referrer/title captured automatically)
juuna.track(event, properties?)            // named event with JSON-serialisable properties
juuna.identify(userId, traits?)            // attach a known user id (+ traits like { email, plan }) to this visitor
juuna.screen(name?, properties?)           // screen view, for non-web clients
juuna.reset()                              // forget the user, start a fresh anonymous identity (call on logout)
juuna.flush()                              // send anything queued right now
```

**Identity model.** On first load the SDK mints an anonymous id and persists it
in `localStorage` (key `juuna_anonymous_id`). `identify(userId)` stitches the
visitor to that user, including their pre-identify anonymous activity, for
timelines, retention and funnels. A **visitor** everywhere in the dashboard is
`userId` if known, else the anonymous id. **Sessions** are derived server-side:
a run of one visitor's events split on a 30-minute inactivity gap.

**SPAs.** Call `juuna.page()` on every client-side route change. The `$scroll`
and `$vital` summaries are emitted when the page is first hidden and attach to
the path current at that moment, so they describe the full page *load* rather
than one virtual route.

**Delivery.** Events batch in memory and flush on `flushAt`/`flushInterval`,
on `flush()`, and automatically when the tab is hidden or closed
(`sendBeacon`/keepalive, so end-of-visit events survive teardown). Transport is
fire-and-forget: the SDK never throws or rejects into the host app.

### Autocaptured events

Autocaptured events are named with a `$` prefix, stored like any event, and
**excluded from the headline metrics and rollups**, so they never inflate
pageview or event counts.

| Event | One per | Properties |
| --- | --- | --- |
| `$click` | click | `x`, `y`: position as fractions (0..1) of the full document width/height. |
| `$scroll` | visible pageview | `depth` (max reached, 0..1), `bands` (10), `attention` (per-band dwell, ms, top → bottom). |
| `$vital` | page load | Any of `lcp`, `fcp`, `ttfb`, `inp` (ms) and `cls` (score). A missing key means "not measured", never 0. |

Web Vitals are rated on Google's thresholds, given here as the good and poor
cutoffs: LCP 2500/4000 ms · INP 200/500 ms · CLS 0.1/0.25 · FCP 1800/3000 ms ·
TTFB 800/1800 ms. At or under the first number is good, over the second is poor,
and anything between the two needs work. The dashboard reports the p75.

### Session replay

Opt-in (`sessionReplay: true`), ESM build only. Recordings are masked by
default (passwords always; every input value unless
`sessionReplayMaskAllInputs: false`), never store the visitor's IP, and are
capped at 30 minutes per session. Per-element control uses rrweb's classes:
`rr-block` removes an element entirely, `rr-ignore` stops input capture,
`rr-mask` masks text. Chunks post to `/api/v1/replay` on an isolated path, so a
replay failure can never break analytics or the host app. Recordings are kept
30 days by default.

## HTTP API

For server-side senders or anything that can't run the SDK. Base URL:
`https://juuna.app`.

**Authentication.** The source's write key, sent any of these ways (checked in
this order):

1. `Authorization: Bearer wk_…` header
2. `x-juuna-write-key: wk_…` header
3. `"writeKey": "wk_…"` in the JSON body

**CORS.** Both endpoints answer preflight and allow any origin at the HTTP
layer; the browser SDK posts as `text/plain` to stay a "simple request" (no
preflight). Separately, if a request carries an `Origin` header and the source
has **allowed domains** configured, the origin's hostname must equal one of
them or be a subdomain of one. Otherwise the request gets a `403`. A source
with an allowlist also rejects requests that carry no `Origin` at all, since a
public write key would otherwise let anyone post from curl: to send from a
server, use a source with no allowed domains, where the write key is the gate.

### POST /api/v1/batch

Ingest up to 250 analytics events in one request (body limit 1 MiB).

```bash
curl -s https://juuna.app/api/v1/batch \
  -H 'authorization: Bearer wk_YOUR_WRITE_KEY' \
  -H 'content-type: application/json' \
  -d '{
    "sentAt": "2026-07-25T12:00:00Z",
    "events": [
      {
        "type": "page",
        "messageId": "9c1d2f34-0000-4000-8000-000000000001",
        "anonymousId": "anon-123",
        "timestamp": "2026-07-25T12:00:00Z",
        "context": { "page": { "path": "/pricing", "url": "https://example.com/pricing" } }
      },
      {
        "type": "track",
        "messageId": "9c1d2f34-0000-4000-8000-000000000002",
        "anonymousId": "anon-123",
        "userId": "user_42",
        "timestamp": "2026-07-25T12:00:05Z",
        "event": "Signup Completed",
        "properties": { "plan": "pro" }
      }
    ]
  }'
# → {"accepted":2,"rejected":0}
```

Event fields:

| Field | Required | Notes |
| --- | --- | --- |
| `type` | yes | `track` \| `page` \| `screen` \| `identify` \| `group` |
| `anonymousId` | yes | Stable per-device id (non-empty string). |
| `timestamp` | yes | ISO-8601. Clamped to "now" if > 1 h in the future or > 400 days in the past. |
| `messageId` | recommended | Client-generated UUID, for tracing. |
| `userId` | no | Known user id; `identify` events upsert the user's traits. |
| `event` | for `track` | The event name. Names starting with `$` are reserved for autocapture. |
| `name` | no | Page/screen name for `page`/`screen`. |
| `properties` | no | JSON object (`track`/`page`/`screen`). |
| `traits` | no | JSON object (`identify`/`group`), e.g. `{ "email": "…", "plan": "pro" }`. |
| `context` | no | `page{url,path,search,referrer,title}`, `screen{width,height}`, `locale`, `userAgent` (server SDKs; browsers rely on the request header), `library{name,version}`. |

The server enriches on ingest: client IP → country/region/city/ISP (MaxMind),
User-Agent → browser/OS/device class, `context.page.referrer` → referrer
source, and UTM parameters parsed from the landing URL's query string.
Individually malformed events are skipped and counted in `rejected`. The
request still succeeds. So does a request from a bot: its events are all
counted in `rejected` and none are stored (see **Data quality**).

Responses:

| Status | Meaning |
| --- | --- |
| `200` | `{"accepted": n, "rejected": m}` |
| `400` | Invalid JSON, or `events` is not an array. |
| `401` | Missing or unknown write key. |
| `403` | Origin not in the source's allowed domains, or absent on a source that has them. |
| `413` | Body over 1 MiB, or more than 250 events in the batch. |
| `429` | Rate limited (per client IP, default 100 requests / 10 s). Honour the `retry-after` header (seconds). |
| `500` | Transient write failure. Safe to retry. |

### POST /api/v1/replay

The session-replay chunk endpoint. Normally only the SDK talks to it; it is
documented for completeness. Same authentication and CORS rules. Body: one
chunk of rrweb events for one session:
`{ sessionId, anonymousId, userId?, seq, sentAt?, meta{url,width,height}?, events: [{type, timestamp, data}] }`
where `seq` is the chunk's monotonic index within the session. Limits: 4 MiB
body, 2000 events per chunk, its own per-IP rate bucket (default 300 / 10 s).
Success → `200 {"accepted": n}`; error statuses mirror the batch endpoint.

## Data quality

Three filters keep the numbers about people rather than requests.

- **Bot user agents are dropped at ingest.** A request whose `User-Agent`
  names a bot is refused server-side: search and AI crawlers, social link
  unfurlers, SEO tools, uptime monitors, headless browsers and page-speed
  tooling, and plain HTTP clients (`curl`, `python-requests`, `okhttp` and
  friends). Analytics events come back counted in `rejected` and are never
  stored; replay chunks are refused with a `403`. Only agents that name
  themselves are dropped: a request with **no** `User-Agent` is unknown, not a
  bot, and is kept, so server-side senders keep working.
- **Automated browsers are ignored by the SDK.** When `navigator.webdriver` is
  set (Playwright, Selenium, Puppeteer, anything driving a real browser),
  `init()` does nothing at all: no events, no autocapture, no recording. This
  is why a crawler that runs JavaScript does not become a visitor twice over.
- **A browser can exclude itself.** Set `juuna_ignore` in `localStorage` and
  the SDK goes quiet in that browser:

```js
localStorage.setItem('juuna_ignore', 'true') // stop counting this browser
localStorage.removeItem('juuna_ignore') // count it again
```

The flag is per browser and per origin, read at `init()`, and read exactly:
only the string `'true'` excludes. It is how you keep your own team's visits
out of your product's numbers. On a juuna instance, dashboard
**Settings → Appearance** has a switch that sets the same flag for the browser
you are reading it in.

## Data handling

- **Retention**: raw events are kept about 90 days (whole-month partitions are
  dropped past the window); session recordings about 30 days. Aggregated
  dashboards read pre-computed rollups and keep working beyond raw retention.
- **IP**: stored on raw events only (never in rollups, never in recordings) and
  used for geo/network enrichment; it ages out with event retention.
- **Erasure**: admins can delete every trace of a person (events, identity
  traits, recordings, by user id or anonymous id, including stitched
  pre-identify activity) from dashboard **Settings → Erase a person's data**.

## Back up your instance

Your instance is one machine with one database, which makes backups the one
piece of housekeeping worth caring about.

- **Recommended: your own bucket.** Give us an S3-compatible bucket at setup
  (Cloudflare R2, Amazon S3, Backblaze B2, or anything else that speaks S3) and
  the instance dumps its whole database there **every hour**, keeping the last
  72. The bucket is yours: we write a token scoped to it onto the instance and
  keep no copy once the instance is live. The setup form has the four fields
  under **Off-box backups**.
- **The default: same-disk dumps.** Without a bucket the instance still dumps
  daily, to its own disk. That covers a bad migration or a deleted table. It
  does not cover losing the machine, which is the case worth having a plan for.
- **Adding it later is not self-serve.** It means writing a credential onto
  your instance, so write to us and we will do it with you. Rotating the token
  later is the same conversation, and takes about a minute.

**What a restore brings back.** A dump is the whole database: every event and
session recording still inside retention, your sources and their write keys,
your funnels, alerts and saved settings, and the password hashes your team
signs in with. Your SDK keeps sending to the same write key, so nothing has to
be re-tagged. What does not come back is anything that was never in the
database: sessions end, so everyone signs in again, and if the address moved
its DNS record and TLS certificate are new.

## For AI agents

Fetch [https://juuna.app/docs/llms.txt](https://juuna.app/docs/llms.txt), which is this page as
plain markdown. The minimal working integration is three steps: (1) get a
`wk_…` write key from a dashboard admin, (2) add the script tag from the
quickstart, or POST batches to `/api/v1/batch` with
`Authorization: Bearer wk_…`, (3) confirm with the `{"accepted":…}` response
or the dashboard's Live view, which shows events within seconds.
