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.

Framework-specific guides, each with copy-paste code and its own markdown mirror: Next.js · React · Astro · SvelteKit · Vue.

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: when the identify arrives, the events that browser already sent under its anonymous id are claimed by the user id, back as far as the retention window, so timelines, retention and funnels see one person rather than two. Events already claimed by a different user id are left alone, so a shared browser never merges two people. A visitor everywhere in the dashboard is userId if known, else the anonymous id. Retention cohorts group visitors by the ISO week (UTC) of their first activity on the source, across all retained history, and the grid always covers the last 12 weeks so cohorts have time to mature. Sessions are derived server-side: a run of one visitor's events split on a 30-minute inactivity gap. A session may span UTC midnight and is counted on the day it started. Daily totals for earlier days are recomputed on the next rollup pass, hourly by default, so a stitch that reaches back a few weeks settles within the hour.

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

Set a User-Agent that names your app. juuna drops generic HTTP clients such as curl and python-requests as bots, so a request without one comes back with "accepted": 0 and a bot_user_agent reason.

curl -s https://juuna.app/api/v1/batch \
  -A 'your-app/1.0' \
  -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, and the retry key (see Retries).
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). Whenever anything is rejected the response also carries reasons, up to ten entries of {index?, code, hint}, where code is bot_user_agent, invalid_event, quota_reached or ip_excluded and index is the event's position in your events array (absent when the reason applies to the whole request). A batch in which everything was stored carries no reasons key at all.

Responses:

StatusMeaning
200{"accepted": n, "rejected": m}, plus reasons when m is not 0.
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.

Retries. messageId is the retry key. Replay a batch with the same messageId and the same timestamp on each event and it is stored once, so a sender that retries a timed out or 500 request does not double count. The reply still reports every event it read in accepted, whether the row was new or a repeat, so a retry never looks like partial loss.

Dedup holds only while the timestamp holds. A timestamp that is missing, unparseable, more than 1 hour in the future or more than 400 days in the past is clamped to the time the batch arrived, and a retry clamped later lands on a different time, which is a different key and a second row. So send a real ISO-8601 timestamp with every event and keep it byte for byte the same across retries. Events with no messageId are never de-duplicated.

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

Four 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. The Overview says how many events this removed over the range you are looking at.
  • 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.

  • A source can ignore whole networks. Settings → Sources → Edit source takes a list of IP addresses and CIDR ranges under Ignore traffic from. A batch whose client address falls in one is refused when it arrives, for everyone rather than for one browser. A second list, Referral exclusions, stops payment and sign-in redirects being counted as new referrals; your allowed domains and their subdomains already are.

Metric definitions

What the dashboard counts, in the same words it prints beside each number: every figure below carries this definition on the card itself, and the whole list is a page of its own inside your instance under How juuna counts. Where juuna counts something differently from the tool you are moving off, the difference is the part of the sentence worth reading.

Autocaptured $click, $scroll and $vital events are excluded from every metric on this page. They are stored for the heatmaps and the Vitals view and never count as a pageview, an event, a session or a visitor.

Traffic

The six figures on the Overview strip, and the lists under them.

  • Visitors: People with at least one counted event in the range, where a person is their user id once they have identified and their anonymous id before that. Each one is counted once for the whole range, so somebody who comes back on three days is one visitor, not three. Only page views and named track events count as being here. A browser that sent nothing but autocaptured clicks, scrolls or vitals is not a visitor.
  • Pageviews: Every page view recorded in the range, counted per view rather than per person: a visitor who reloads a page five times adds five.
  • Sessions: A run of one visitor’s events with no gap longer than 30 minutes between them. Every counted event takes part, not only page views, so a product that sends mostly track events still has sessions. A session that runs past UTC midnight stays one session and is counted on the day it started. Its page views still count on the day each one happened.
  • Bounce rate: The share of sessions that hold exactly one counted event. A visit with one page view plus one named track event is not a bounce here, and an autocaptured click never counts either way, so this figure reads lower than the single-page-view bounce rate most other tools report. Google Analytics 4 reports the inverse of its engaged sessions, which counts a visit of more than ten seconds as engaged whatever it did, so neither it nor the single-page-view rule will land on this number.
  • Avg visit: Total session length divided by sessions, where a session is as long as the time from its first event to its last. Time on the last page cannot be measured, so a one-event session counts as zero seconds and still sits in the divisor. A tool that measures foreground engagement time instead, as Google Analytics does, is measuring something else and will report a larger number over the same visits.
  • Events: Every page view plus every named track event in the range. Autocaptured $click, $scroll and $vital rows are stored for the maps and the Vitals view and are never counted here, so over the same range Events equals Pageviews plus the total of Top events. Top events lists the named track events on their own, which is why its total is the smaller of the two figures.
  • Top sources: Visitors grouped by where they came from, under one canonical name: the utm_source the visit carried, else the referrer, else the ad network behind a click id. Each visitor is counted once, under the first source they arrived on, so the rows add up to Visitors. Spellings are folded onto one name, so twitter, Twitter / X and x.com are all Twitter, and the Overview and Acquisition read this list from the same query so the two give one answer for Google. Top campaigns on Acquisition counts only the visitors who arrived carrying a utm tag, which is a subset of this list.
  • Top pages: Page views per path over the range, so one visitor reading three pages appears in three rows and the column adds up to Pageviews. Under a filter it counts the same thing over the sessions that matched, so the column always means page views.
  • Countries: Visitors grouped by the country their IP address resolved to, each counted once under the first country they were seen in, so the rows add up to Visitors. Visitors whose events carried no country at all land in Unknown. The map colours only the located ones, which is why it reads as a share of located visitors and the card does not.

Engagement

Who is active, and how often they come back.

  • DAU, WAU and MAU: Active visitors on the last day of the range, over the seven days ending there, and over the thirty days ending there. Each window de-duplicates on its own, so a visitor active every day counts once in all three. All three are read from that last day rather than from the range you selected, so widening the range moves the day they describe, not the width of their windows.
  • Stickiness: DAU divided by MAU on the last day of the range: the share of the past month’s visitors who came back that day. Both halves are counts of people, so this is a ratio of people rather than of visits.

Acquisition

Where the visitors came from, and which of them were tagged.

  • Campaign visitors: People who touched a campaign at any point in the range, counted once each: anyone whose visits carried a utm_source, utm_medium or utm_campaign tag. It is the population Top campaigns ranks, and together with No campaign touch it adds up to Visitors. The Acquisition table below asks a different question, filing each visitor under the channel, source and campaign they FIRST arrived on, so its tagged rows add up to less than this figure whenever somebody arrived untagged and clicked a campaign later.
  • Campaign sessions: Sessions holding at least one tagged event, over the same session definition the rest of the product uses: a run of one visitor’s events with no gap longer than 30 minutes. One person can bring several, which is why this figure can read higher than Campaign visitors. A session counts on the day it started, so the base this is a share of is the Sessions figure the Overview reads for the same range.
  • No campaign touch: People who carried no utm tag at all in the range, counted once each. Direct arrivals, organic search and plain referrals are all in here, so this is neither the Direct channel on its own nor the dashed rows of the Acquisition table. Those dashed rows are the larger set: they are the visitors whose first arrival carried no tag, which includes somebody who clicked a campaign later and is a campaign visitor here.
  • Campaigns: How many campaigns visitors actually arrived on in the range, counting the distinct utm_campaign values on their first tagged visit. It is how many rows Top campaigns has to rank, whether or not they all fit on the card. A tagged visit that named no campaign is not one of them.
  • Channels: Visitors grouped into exactly one of Paid, Email, Social, Organic search, Referral or Direct, decided by six rules read in that fixed order from the visit’s utm_medium, its source and any ad network click id. Each visitor is counted once, under the channel of their first visit in the range, so the rows add up to Visitors. The rules live in the database rather than in a per-source editor: a click id is Paid even with no medium, a known search engine is Organic search, and any other named source is Referral. A visitor whose visits recorded nothing to classify lands in Unknown.
  • Top campaigns: Visitors who arrived carrying a utm_source, utm_medium or utm_campaign tag, grouped by the campaign on their first tagged visit in the range. Traffic that carried no utm tag is absent here and present in Top sources beside it, so this list is a subset of that one and reads lower. A tagged visit that named no campaign of its own is not ranked here, because it is not a campaign, but it still counts in the total the shares divide by, so the card’s last line discloses it.
  • Conversions: People who completed the goal event picked on the Acquisition view, counted once each, at or after the arrival that view files them under. Somebody who signed up before the campaign touch that brought them back is not credited to it. The goal is part of the page address rather than a saved setting, so no event is marked as a conversion anywhere: pick another one and every figure on the view, and its ranking, follow it.
  • Conversion rate: Conversions over the visitors they came from, both counted the same way over the same range. On the strip that is every visitor in range, and in a table row it is that row’s own visitors, so a rate never divides by a number the page does not print. A visitor who converts days later, in a session of their own, still counts. There is no conversion window inside the range.

Replay

The figures above the recorded sessions.

  • Recordings: Session recordings whose session started in the range, counted after any replay filter has been applied. One recording covers one session, so a visitor who came back twice in the range has two. Recording is opt-in in the SDK and can be sampled, so a visit counted everywhere else on the dashboard may have no recording here, and recordings are pruned sooner than the events behind them are.
  • Total recorded: The length of every recording in the range added together. It is not a count of recordings and not what any one visitor sat through. Like the other three figures on this strip it covers every recording that matches, including the ones on later pages of the list.

Web Vitals

What real browsers reported about the page itself.

  • p75: The value three quarters of measured page loads came in at or under. Web Vitals are read this way so one slow outlier cannot move the headline. juuna interpolates between the two samples nearest the percentile, so on a small sample it can differ slightly from a tool that reports one sample itself.
  • Largest Contentful Paint (LCP): How long the largest thing on the screen takes to appear. It is measured in real visitors’ browsers, once per page load, and reported as a p75.
  • Interaction to Next Paint (INP): How long the page takes to respond after a tap, click or key press. It is measured in real visitors’ browsers, once per page load, and reported as a p75.
  • Cumulative Layout Shift (CLS): How much the page moves under the reader while it loads. It is measured in real visitors’ browsers, once per page load, and reported as a p75.
  • First Contentful Paint (FCP): How long the page takes to draw anything at all. It is measured in real visitors’ browsers, once per page load, and reported as a p75.
  • Time To First Byte (TTFB): How long the server takes to send the first byte of the page. It is measured in real visitors’ browsers, once per page load, and reported as a p75.

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. The address itself is visible to admin accounts only: on raw events in the live event stream, on a visitor's timeline, and as a column of the raw CSV export. Reader accounts see the derived country, city and network everywhere those appear, and never the address.
  • 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 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.