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 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)
<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:
<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:
npm install @muziris/juuna # or pnpm add / yarn addimport { 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
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 nowIdentity 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):
Authorization: Bearer wk_…headerx-juuna-write-key: wk_…header"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).
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-Agentnames 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,okhttpand friends). Analytics events come back counted inrejectedand are never stored; replay chunks are refused with a403. Only agents that name themselves are dropped: a request with noUser-Agentis unknown, not a bot, and is kept, so server-side senders keep working. - Automated browsers are ignored by the SDK. When
navigator.webdriveris 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_ignoreinlocalStorageand the SDK goes quiet in that browser:
localStorage.setItem('juuna_ignore', 'true') // stop counting this browser
localStorage.removeItem('juuna_ignore') // count it againThe 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, 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.