Analytics
Cookieless page views, custom events, visits, sessions and funnels, stored next to your traces and errors.
Analytics shows how people move through your site: pageviews, custom events, where visits come from and where they stop. It uses no cookies, no browser storage and no user identity. Events land in the same project as your traces, so a product question and a performance question live one click apart. Open it from Analytics in a project's sidebar (/o/:org/projects/:project/analytics).
Send events
From the browser, add the analytics() integration to @unhandled/browser. It records the first page and every path change, and exposes track() for custom events. The browser SDK page covers every option.
import { analytics, errors, initUnhandled, network } from '@unhandled/browser'
export const unhandled = initUnhandled({
dsn: import.meta.env.VITE_UNHANDLED_DSN,
environment: 'production',
integrations: [network(), errors(), analytics({ ignorePaths: [/^\/admin/] })],
})
unhandled.track({ name: 'newsletter_signup', properties: { source: 'footer' } })From an AdonisJS backend, call Unhandled.track(name, properties) from @unhandled/agent-adonisjs. It validates the event before sending and returns a TrackResult, so you can log rejections instead of losing them.
import logger from '@adonisjs/core/services/logger'
import { Unhandled } from '@unhandled/agent-adonisjs'
const result = Unhandled.track('invoice_paid', { plan: 'pro', amount: 49 })
if (!result.ok) {
logger.warn({ code: result.error.code }, 'analytics event rejected')
}Server-side events carry no site, path or referrer, so they count toward event totals but never toward visitors, visits or funnels.
Ingestion format
Both SDKs send analytics as OTLP log records to /v1/logs with the usual x-api-key, x-unhandled-service-id and x-unhandled-env headers (see OpenTelemetry endpoints). A record is treated as analytics when its instrumentation scope name is unhandled.analytics. Its body is a map with event_id, occurred_at_ms, kind (pageview or event), name, site, path, referrer and properties. UTM values travel as the attributes unhandled.analytics.utm_source, utm_medium, utm_campaign, utm_term and utm_content under the same prefix.
The ingest endpoint validates every event again, whoever sent it:
| Rule | Limit |
|---|---|
| Event name | 1 to 100 bytes, not pageview |
| Path | the query and fragment are removed, then at most 2,048 bytes |
| Referrer | the query and fragment are removed, then at most 2,048 bytes |
event_id | at most 64 bytes, otherwise replaced by a hash of the event |
kind | pageview or event, anything else is stored as event |
occurred_at_ms | from 24 hours before to 60 seconds after the time the ingest received the event, otherwise the receive time is used |
| Site | empty or a hostname of at most 253 bytes |
| Properties | at most 20 |
| Property key | 1 to 64 bytes, not reserved |
| String value | at most 256 bytes |
| Number value | finite |
| UTM value | values over 128 bytes are dropped |
The path, referrer, event_id, kind and timestamp rules fix the event instead of rejecting it, so a sender that sends too much still gets counted.
Reserved keys are user_id, userid, email, distinct_id, anonymous_id and session_id, plus any key starting with user., trace., span. or unhandled.. A rejected event is counted in the OTLP partial success response with a message such as analytics event rejected: reserved_property_key.
Visitors and privacy
Unhandled never stores an IP address or User-Agent with an analytics event. It derives a visitor ID instead: an HMAC-SHA256 of the site, the client address and User-Agent (the client.address and user_agent.original attributes when present, otherwise the ingest request's), keyed with a random secret for the current UTC day, truncated to 32 hex characters. A new secret is generated for each day and the server deletes secrets older than yesterday, so the same person gets an unrelated ID the next day and an old ID can no longer be recomputed. The secret stays in the server's database, is never sent to ClickHouse and never appears in logs. An event takes the secret of the day it arrives, not the day in its timestamp.
Events received in the first 30 minutes of a UTC day continue yesterday's visit. When yesterday's ID for the same site, address and User-Agent was active within the 30 minutes before the event, the event keeps yesterday's ID, so a visit that crosses midnight is not split. A visit that is still going 30 minutes after midnight splits at that point.
Bots and automated browsers
Before a visitor ID is computed, the ingest drops analytics events whose User-Agent (the user_agent.original attribute, otherwise the request's) matches an ordered list of rules. The first match names the reason:
| Reason | Matches |
|---|---|
headless | HeadlessChrome, PhantomJS, SlimerJS, Lighthouse, Playwright, Puppeteer, Nightmare, webdriver |
bot_ua | the isbot list of crawlers, monitors and HTTP libraries such as curl |
A dropped event is not stored and does not count as rejected. The server counts drops per reason and logs them. A request without a User-Agent is kept. The browser SDK also sends nothing when navigator.webdriver is true, unless you pass trackAutomatedBrowsers: true.
On a self-hosted server, two environment variables of the web app tune the filter. The ingest drain (node ace ingest:drain) reads them:
| Variable | Meaning |
|---|---|
ANALYTICS_BOT_FILTER | off stores every event. The default is on. |
ANALYTICS_ALLOWED_USER_AGENTS | Comma-separated, case-insensitive fragments. A User-Agent containing one is never dropped, for example your own monitor. |
Note. Servers that send analytics without
user_agent.originalandclient.addressare judged by their own HTTP client User-Agent, which usually matchesbot_ua. Set the two attributes from the visitor's request, or allow the client's User-Agent.
Note. For browser events (those with a site) the ingest endpoint takes the address and User-Agent from the request when the record has no
client.addressoruser_agent.originalattribute, hashes them in memory and stores only the hash. Server-side events have no site, so they never get a visitor ID from the request. Events without a visitor ID still count as pageviews and events, but visitors, visits, sessions, bounce rate and funnels only include events that have one.
Visits
Unhandled assembles visits when you open a page, not at ingest time, so new events show up in every view immediately.
- A visit groups the events of one visitor on one site. It ends after 30 minutes without activity. A visitor ID that stays active across midnight (see Visitors and privacy) keeps one visit.
- A visit needs at least one pageview. Activity without a pageview is not a visit.
- A visit belongs to the time range in which it starts.
- Its source is the referrer hostname of its first pageview, without
www.. No referrer shows asDirect. - Its UTM values come from its first pageview.
Overview
The Overview tab shows these metrics for the selected range, each with a chart you pick by clicking its card:
| Metric | Definition |
|---|---|
| Estimated visitors | Distinct visitor IDs per site per UTC day, summed. A person seen on two days counts twice. |
| Visits | Visits that start in the range. |
| Pageviews | Recorded pageviews. An event named page_view counts as one too. |
| Events | Custom events. |
| Visit duration | Average time from first to last activity. A visit with one activity lasts 0 s. |
| Bounce rate | Share of visits with exactly one pageview, even if they sent events. |
| Conversion | Shown only while an Event filter is set: estimated visitors who sent that event, divided by estimated visitors under the other filters. |
Each card compares with the equally long period just before the range. When that period would start more than 90 days ago, no comparison is shown.
The site picker lists every hostname that sent analytics in the range, plus All sites. The visitor ID includes the site, so one person on two of your sites counts as two visitors under All sites. The time range and environment come from the project pickers.
Top lists
Four cards list the top 10 values. Clicking a row filters the whole Overview on it.
| Card | Tabs | Counts |
|---|---|---|
| Pages | Pages, Entries, Exits | pageviews for Pages, visits for Entries and Exits |
| Sources | Referrers | visits |
| Campaigns | Source, Medium, Campaign, Term, Content | visits |
| Events | none | events |
Filters
Available filters are Page, Entry page, Exit page, Source, UTM source, UTM medium, UTM campaign, UTM term, UTM content and Event. Each takes one exact value, filters combine with AND, and they live in the URL so a filtered view can be shared. Every filter selects whole visits: filtering on Event purchase keeps all activity of the visits that sent purchase. Filters apply to the Overview tab only.
Event properties
With an Event filter set, an Event properties card shows one tab per property key. A value is listed once at least 3 estimated visitors sent it, up to 10 values per key. When every value of a key is numeric, the card also shows its sum, average, minimum and maximum. You cannot filter on properties.
Pages
The Pages tab lists up to 200 pages with Pageviews, Visitors, Visits, Bounce rate and Avg time on page.
- Routes groups paths into templates and lets you expand a route to its top 10 URLs. URLs lists each path on its own.
- The route is the event's
routeproperty when it has one, otherwise it is computed from the stored path. Numeric segments, UUIDs, hex strings of 8 or more characters, alphanumeric tokens of 16 or more characters that contain a digit, and segments the browser SDK replaced withREDACTEDbecome:id. Readable slugs stay as they are. - Bounce rate here is the share of visits that entered on the page and viewed no other page.
- Avg time on page is the time until the next pageview of the same visit. A visit's last page is left out because its time is unknown.
- Sort by pageviews, visitors or name, and search the path (case-insensitive).
Events
The Events tab lists up to 100 custom events with a trend, count, estimated visitors and conversion, which is the event's visitors divided by all visitors in the range. Clicking an event opens the Overview filtered on it, including its Event properties card.
Sessions
The Sessions tab lists visits, 50 per page, with start time, source, campaign, entry page, exit page, pageviews, events and duration. Sort by newest, duration, pageviews or events, and search entry or exit pages. A Live badge marks visits with activity in the last 5 minutes, and Bounced marks visits with a single pageview.
A visit's page shows its journey, every pageview and event in order with its offset from the start, capped at the first 200 steps. Events show their property keys but never their values. The page also lists the services involved in the visit.
Funnels
A funnel counts visits that complete a sequence of steps. Build one on the Funnels tab with 2 to 8 steps of these types:
| Step | Operators | Example value |
|---|---|---|
| Page view (URL) | is, contains | /checkout |
| Page view (route) | is, contains | /products/:id |
| Custom event | is | order_placed |
A step takes up to 20 comma-separated values and matches any of them. contains is a case-sensitive substring match.
Steps must happen in order inside one visit. Other activity can happen in between, but one pageview or event advances the funnel by one step at most, and a funnel never spans visits or days. For each step the results show the visits that reached it, conversion from the first and from the previous step, and the median time since the previous step. The "dropped off" link opens Sessions filtered to the newest 1,000 visits that stopped right before that step. Two tables, Conversion by entry page and Conversion by source, split entries and completions for the top 10 values by entries.
The steps live in the URL, so you share a funnel by sharing its link. Unhandled does not save funnels yet.
Retention and limits
- Analytics events are kept for your organization's analytics retention: at least 90 days on every plan, unless an admin shortens it. See Data retention. Queries never reach more than 90 days back.
- Unhandled does not record country, region, city, browser, operating system, device or screen size.
- The browser SDK does not check Do Not Track. To honor it, leave
analytics()out ofintegrationswhennavigator.doNotTrack === '1'.