A Muziris product

04 · Docs

One tag.Every event.

Everything needed to send events to juuna: the script tag, the npm package, what autocapture records on its own, and the HTTP API behind them both. Every example is written for this instance, so it is copy-paste correct.

Plain markdown for AI agents · /docs/llms.txt

llms.txt

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 add
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:

OptionDefaultWhat it does
apiHostnoneBase URL of this juuna instance, no trailing slash. Set it to https://juuna.app.
flushAt20Send the queued batch once this many events are buffered.
flushInterval5000Also flush at most this often (ms) while events trickle in.
clickTrackingfalseAutocapture click positions as $click events (normalised page coordinates only, never element contents). Powers the click heatmaps.
scrollTrackingfalseAutocapture per-pageview scroll behaviour as one $scroll event (max depth + coarse per-band dwell). Powers the scroll/attention maps.
webVitalsfalseMeasure 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.
sessionReplayfalseRecord the session with rrweb (ESM build only; lazy-loaded chunk).
sessionReplaySampleRate1Fraction of sessions to record, in [0, 1]. Sticky per tab.
sessionReplayFlushIntervalMs2000How often (ms) buffered replay events ship to the server, clamped to [500, 10000]. Sets the floor of live-view latency.
sessionReplayMaxMinutes30Hard cap on one recording's length, in minutes, clamped to [1, 240]. The server enforces the same limit.
sessionReplayMaskAllInputstrueMask every input value in recordings. Passwords are always masked regardless.
sessionReplayMaskAllTextfalseAlso 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 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.

EventOne perProperties
$clickclickx, y: position as fractions (0..1) of the full document width/height.
$scrollvisible pageviewdepth (max reached, 0..1), bands (10), attention (per-band dwell, ms, top → bottom).
$vitalpage loadAny 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).

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:

FieldRequiredNotes
typeyestrack | page | screen | identify | group
anonymousIdyesStable per-device id (non-empty string).
timestampyesISO-8601. Clamped to "now" if > 1 h in the future or > 400 days in the past.
messageIdrecommendedClient-generated UUID, for tracing.
userIdnoKnown user id; identify events upsert the user's traits.
eventfor trackThe event name. Names starting with $ are reserved for autocapture.
namenoPage/screen name for page/screen.
propertiesnoJSON object (track/page/screen).
traitsnoJSON object (identify/group), e.g. { "email": "…", "plan": "pro" }.
contextnopage{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:

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