# Privatus Analytics documentation
> Privacy-first, cookieless web analytics. This file contains every page
> of https://privatusanalytics.com/docs as Markdown. The generated API reference is at
> https://privatusanalytics.com/docs/api and the OpenAPI document at https://privatusanalytics.com/openapi.json.
---
Source: https://privatusanalytics.com/docs/getting-started
# Get started with Privatus Analytics
> Set up cookieless web analytics in about five minutes: create an account, add a site, install the tracker, verify it works and see your first visitors.
Privatus Analytics is cookieless web analytics. The tracker is one small
script with no cookies, no local storage and no fingerprinting, so in most
setups you don't need a consent banner for it. This section takes you from
sign-up to your first goal.
## The five-minute version
1. [Create an account](/docs/getting-started/create-account). The Free plan
needs no card and includes 10,000 events a month.
2. [Add a site](/docs/getting-started/add-a-site): its domain and
timezone.
3. Paste the snippet into the `
` of every page:
```html
```
Your site id (`pa_…`) is under **Site settings → Tracking**. It isn't a
secret: it's visible in your page source, like the domain.
4. [Verify the installation](/docs/getting-started/verify-installation). The
check fetches your page, looks for the script and warns about headers
that block it.
5. [Create your first goal](/docs/getting-started/first-goal) and
[invite your team](/docs/getting-started/invite-your-team).
Using a CMS, site builder or framework? Pick your platform in the
[install guides](/docs/install).
## What you get straight away
- **Visitors, visits, pageviews, bounce rate and visit duration** on the
[Overview](/docs/dashboard/overview).
- **Sources and channels**, including a separate channel for
[AI assistants](/docs/metrics/channels) such as ChatGPT and Perplexity.
- **Pages, entry and exit pages, locations, devices, browsers** and
**UTM campaigns**.
- **Time on page and scroll depth** from the `engage` module, which is on
by default.
- **Real-time** visitors on the [Live](/docs/dashboard/live) page.
Add [custom events](/docs/events), [goals](/docs/features/goals) and
[funnels](/docs/features/funnels) when you're ready.
## How it stays private
- No cookies, `localStorage`, `sessionStorage` or IndexedDB. The only
exception is a flag a visitor sets themselves when they
[opt out](/docs/privacy/opt-out).
- The visitor's IP address and User-Agent are used in memory to work out
the country, the device and a daily visit key, and are then dropped. They
are never written to disk or logs.
- Every visitor, including visitors from the EU, UK and Switzerland, is
processed in our US region. Only the minimized event is stored.
Read [how it works](/docs/privacy) for the full picture, and
[how visitors are counted](/docs/metrics/how-visitors-are-counted) for the
details of visits and visitors.
---
Source: https://privatusanalytics.com/docs/getting-started/create-account
# Create a Privatus Analytics account
> Sign up for Privatus Analytics with Google, GitHub or just your email (no password needed) and create a workspace for your sites and team.
## Sign up
Go to **Sign up** on the website and pick one of three options:
- **Sign up with Google** or **Sign up with GitHub**: we use the verified
email address of that account.
- **Send me a login link**: enter your email and open the link we send
you. It works once, for 15 minutes. Opening it creates your account and
signs you in. Nothing is created until you open it, so a mistyped
address leaves no account behind.
The same "Email me a login link" option on the login page also creates an
account for an email that doesn't have one yet. You can add a password, a
passkey or two-factor authentication later under **Account settings →
Security** (see [Two-factor authentication](/docs/teams/two-factor)).
Continuing with any option confirms you are 18 or older and accepts the
Terms of Service, which include our Data Processing Agreement (DPA), and
the Privacy Policy. You don't need to sign anything else to use Privatus Analytics
with personal data under the GDPR. See [Privacy & compliance](/docs/privacy).
## Your email is already verified
Google, GitHub and the email link all prove that you own the address, so
there is no separate confirmation email.
## Create a workspace
A **workspace** holds your sites, members, billing and API tokens. Most
people need one. Agencies often create one per client, or one workspace
with per-site access for each client (see [Teams](/docs/teams)).
Choose:
- **Name**: shown in the workspace switcher and in emails.
- **Default timezone**: new sites start with it. Each site can have its own.
- **Default currency**: used to total [revenue](/docs/events/revenue).
## Next
[Add your first site](/docs/getting-started/add-a-site).
---
Source: https://privatusanalytics.com/docs/getting-started/add-a-site
# Add a website to Privatus Analytics
> Add a website to Privatus Analytics: set its domain, extra domains, timezone and currency, and find the site id for the tracking snippet.
A **site** is one website you measure. Each site has its own settings,
goals, sharing and data, and a public id like `pa_7Q2K9XH3AB` that goes in
the tracking snippet.
## Fields
| Field | What it does |
|---|---|
| Domain | The main hostname, e.g. `example.com`. Hits from other hostnames are dropped unless you add them as extra domains. Subdomains (`www.`, `shop.`) of a listed domain are accepted. |
| Name | A display name. Defaults to the domain. |
| Timezone | Where "today" starts and ends in reports. Stored data is UTC. The timezone is applied when you query, so you can change it later. |
| Currency | The currency revenue is converted to. |
## Several domains
Under **Site settings → General** you can add extra domains, for example a
separate checkout domain or a docs site. Choose whether they're **combined**
(one visit can cross them) or **separate** (the hostname is part of the
visit key, so the same visitor on two domains is two visits). The
[cross-domain troubleshooting page](/docs/troubleshooting/cross-domain)
explains the trade-offs.
## Plan limits
The Free plan includes 10 sites. Paid plans have no site limit. All sites
share the workspace's monthly event allowance.
## Next
[Install the tracker](/docs/install) on your site, then
[verify the installation](/docs/getting-started/verify-installation).
---
Source: https://privatusanalytics.com/docs/getting-started/verify-installation
# Verify your analytics installation
> Check that the Privatus Analytics tracker is installed and hits are arriving, with the automatic check, the Live page or your browser's network tab.
## Automatic check
Open **Site settings → Tracking** and click **Verify installation**. We
fetch your homepage (or a URL you enter on the same domain) and report:
- whether a `
```
- Replace `pa_YOURSITEID` with your site id from **Site settings →
Tracking**. The snippet generator on that page builds the tag for you,
including any options you switch on.
- `defer` loads the script without blocking your page. The core script is
a few kilobytes and sets no cookies.
- Add options as `data-` attributes. The most common:
| You want to… | Add |
|---|---|
| Track outbound links, downloads, forms and 404s automatically | `data-modules="engage,auto"` |
| Measure Web Vitals | `data-modules="engage,vitals"` |
| Track a hash-routed single-page app | `data-spa="hash"` |
| Send pageviews yourself | `data-manual="true"` |
| Skip admin pages | `data-exclude="/admin/*"` |
The full list is in the [attribute reference](/docs/tracker/attributes).
> **Warning:** Setting `data-modules` replaces the default list. The
> `engage` module (time on page and scroll depth) is on only when you leave
> `data-modules` out or include `engage` in it.
## Choose your platform
**Websites and site builders:** [HTML](/docs/install/html) ·
[WordPress](/docs/install/wordpress) · [Shopify](/docs/install/shopify) ·
[Webflow](/docs/install/webflow) · [Squarespace](/docs/install/squarespace) ·
[Wix](/docs/install/wix) · [Framer](/docs/install/framer) ·
[Ghost](/docs/install/ghost)
**Tag managers:** [Google Tag Manager](/docs/install/google-tag-manager) ·
[Cloudflare Zaraz](/docs/install/cloudflare-zaraz)
**Frameworks:** [Next.js](/docs/install/nextjs) · [Nuxt](/docs/install/nuxt) ·
[React](/docs/install/react) · [Vue](/docs/install/vue) ·
[SvelteKit](/docs/install/sveltekit) · [Astro](/docs/install/astro) ·
[Remix](/docs/install/remix) · [Angular](/docs/install/angular)
**Static site generators:** [Hugo](/docs/install/hugo) ·
[Jekyll](/docs/install/jekyll) · [Eleventy](/docs/install/eleventy) ·
[Docusaurus](/docs/install/docusaurus)
**Other:** [AMP](/docs/install/amp) · [No-JavaScript pixel](/docs/install/pixel) ·
[Electron and browser extensions](/docs/install/electron-and-extensions) ·
[Server-side](/docs/server-side)
## After installing
[Verify the installation](/docs/getting-started/verify-installation). If
nothing arrives, work through [No data](/docs/troubleshooting/no-data).
---
Source: https://privatusanalytics.com/docs/install/html
# Add the analytics script to an HTML site
> Add privacy-friendly analytics to a hand-written or server-rendered HTML site with one script tag, turn on engagement and auto events, and track custom events.
Paste the snippet just before `` on every page. With a shared
layout or header template you only need to do this once.
```html
My site
…
```
## Recommended options
```html
```
- `engage` measures time on page and scroll depth.
- `auto` tracks outbound links, file downloads, `mailto:`/`tel:` links,
form submissions and 404 pages as events. See [Modules](/docs/tracker/modules).
## Track events in your own code
Add the [queue stub](/docs/tracker/javascript-api#queue-stub) before the
tracker if you call the API early (for example in inline scripts that run
before `pa.js` has loaded):
```html
```
Then anywhere on the page:
```js
privatus.track('Newsletter signup', { list: 'weekly' })
```
Or with no JavaScript at all:
```html
Subscribe
```
## No JavaScript?
Add the [pixel](/docs/install/pixel) inside a `` tag to count
visitors with JavaScript disabled.
---
Source: https://privatusanalytics.com/docs/install/wordpress
# Install Privatus Analytics on WordPress
> Install the Privatus Analytics WordPress plugin or add the snippet to your theme, exclude admins, track WooCommerce purchases and add an opt-out shortcode.
## With the plugin (recommended)
1. In WordPress go to **Plugins → Add New** and search for
**Privatus Analytics**. If it isn't listed in the plugin directory yet,
[contact us](/contact) for the plugin zip and upload it under
**Plugins → Add New → Upload Plugin**, or upload the
`privatus-analytics` folder to `/wp-content/plugins/`.
2. Activate it.
3. Go to **Settings → Privatus Analytics** and paste your site id (`pa_…`).
The plugin adds the tracker to every page and exposes every tracker option
(SPA mode, modules, excluded paths, masks, extra query parameters,
canonical URLs, hash routing).
It also:
- **excludes logged-in users by role** (administrators by default), so your
own visits don't count,
- tracks **WooCommerce purchases** as a `purchase` event with revenue,
currency, the number of items and whether a coupon was used. It never
sends order ids, names, emails or addresses, and masks order-specific
URLs,
- offers a **server-side purchase mode** that sends the purchase from your
server with a secret [ingest key](/docs/server-side/ingest-keys). Put the
key in `wp-config.php` to keep it out of the database:
```php
define( 'PRIVATUS_INGEST_KEY', 'pik_…' );
```
- adds a `[privatus_optout]` shortcode for your privacy page (see
[Opt-out](/docs/privacy/opt-out)),
- suggests privacy policy text under **Settings → Privacy**,
- shows your [shared dashboard](/docs/reports/sharing) in a dashboard
widget.
### Filters for developers
| Filter | Type | Use |
|---|---|---|
| `privatus_analytics_should_track` | bool | Return `false` to skip tracking on a request |
| `privatus_analytics_script_attributes` | array | Change the attributes of the script tag |
```php
add_filter( 'privatus_analytics_script_attributes', function ( $attrs ) {
$attrs['data-modules'] = 'engage,auto,vitals';
return $attrs;
} );
```
## Without the plugin
Add the snippet to your theme's `header.php` before ``, or with a
"header and footer scripts" plugin. In a child theme's `functions.php`:
```php
add_action( 'wp_head', function () {
if ( current_user_can( 'manage_options' ) ) {
return; // don't count administrators
}
echo '';
} );
```
> **Warning:** Caching and optimization plugins (WP Rocket, Autoptimize,
> LiteSpeed Cache…) sometimes combine or delay scripts. Exclude `pa.js`
> from "combine", "defer JS" and "delay JS" features. See
> [Caching plugins](/docs/troubleshooting/caching).
---
Source: https://privatusanalytics.com/docs/install/shopify
# Install Privatus Analytics on Shopify
> Add cookieless analytics to your Shopify store with the app embed or theme code, then track checkout purchases and revenue with a Shopify custom pixel.
## Storefront
1. Install the **Privatus Analytics** app. If it isn't listed in the
Shopify App Store yet, use the steps under [Without the app](#without-the-app).
2. Go to **Online Store → Themes → Customize → App embeds** and switch on
**Privatus Analytics**.
3. Enter your site id (`pa_…`) and choose modules. Engagement is on by
default. Auto events, Web Vitals and click maps are optional.
4. Save.
The embed prints the tracker in the theme's `` and masks
customer-specific order pages (`/account/orders/*`). It isn't loaded in the
theme editor, so editing your theme doesn't add visits.
### Without the app
Edit your theme (**Online Store → Themes → … → Edit code**), open
`layout/theme.liquid` and add the snippet before ``:
```liquid
```
## Checkout and purchases
Shopify's checkout doesn't run theme scripts. Use a **custom pixel** under
**Settings → Customer events → Add custom pixel**:
```js
// Privatus Analytics: purchases (runs in Shopify's sandbox)
const SITE = 'pa_YOURSITEID'
const ENDPOINT = 'https://privatusanalytics.com/api/event'
function send(body) {
fetch(ENDPOINT, {
method: 'POST', keepalive: true, credentials: 'omit',
headers: { 'Content-Type': 'text/plain' },
body: JSON.stringify(Object.assign({ s: SITE }, body))
})
}
analytics.subscribe('checkout_completed', (event) => {
const checkout = event.data.checkout
send({
t: 'event', n: 'purchase', u: event.context.document.location.href,
p: {
revenue: Number(checkout.totalPrice.amount),
currency: checkout.totalPrice.currencyCode,
items: checkout.lineItems.length
}
})
})
```
Set **Permission** to "Not required" only if your legal assessment allows
it. Privatus Analytics sets no cookies and stores nothing on the device. Never add
order ids, emails or names as properties.
Your storefront domain (`shop.example.com`) and your `myshopify.com`
domain may both appear. Add both as [extra domains](/docs/getting-started/add-a-site#several-domains)
if you want hits from both.
---
Source: https://privatusanalytics.com/docs/install/webflow
# Add Privatus Analytics to Webflow
> Add privacy-friendly analytics to Webflow through site-wide custom code, handle your webflow.io staging domain and track events with custom attributes.
1. Open your project and go to **Site settings → Custom code**.
2. Paste the snippet into **Head code**:
```html
```
3. Save and **Publish**. Custom code only runs on the published site, not
in the Designer.
## Staging domain
Your `*.webflow.io` staging domain isn't one of your site's domains, so its
hits are dropped. That's usually what you want. To measure it, add it as an
extra domain, or restrict tracking to production explicitly with
`data-domains="example.com,www.example.com"`.
## Events without code
Webflow lets you add custom attributes to any element (**Element settings →
Custom attributes**). Add:
| Name | Value |
|---|---|
| `data-privatus-event` | `Demo request` |
| `data-privatus-prop-plan` | `business` |
On a form block, the event fires when the form is submitted. See
[HTML attributes](/docs/events/html-attributes).
---
Source: https://privatusanalytics.com/docs/install/squarespace
# Add Privatus Analytics to Squarespace
> Add cookieless analytics to Squarespace with header code injection, and track purchases with revenue from the Order Status Page without sending order details.
Code injection needs a Squarespace plan that includes it (Core and above
on current plans).
1. Go to **Settings → Advanced → Code Injection** (or **Website → Pages →
Custom code → Code injection**).
2. Paste the snippet into **Header**:
```html
```
3. Save.
Squarespace 7.1 loads pages with full navigations, so the default settings
work. If you use AJAX page loading on a 7.0 template, the tracker still
follows `history.pushState` navigations (`data-spa="auto"`).
## Order confirmation
Under **Code Injection → Order Status Page**, Squarespace can run a script
after a purchase. The tracker from the header is already on that page, so
you only need the event:
```html
```
Replace the variable with the one your plan's order page offers, and never
send order numbers or customer details.
---
Source: https://privatusanalytics.com/docs/install/wix
# Add Privatus Analytics to Wix
> Add privacy-friendly analytics to a Wix site as custom code in the head, count page navigations once and set the code type to Essential for your consent banner.
Custom code requires a premium Wix plan with a connected domain.
1. In the dashboard go to **Settings → Custom code → Add custom code**.
2. Paste the snippet:
```html
```
3. Name it "Privatus Analytics", choose **All pages**, **Load code once**,
and place it in the **Head**.
4. Apply.
Wix sites navigate between pages without full reloads. The tracker follows
those navigations with its default `data-spa="auto"`, so each page counts
once.
Under **Code type**, choose **Essential** if your Wix consent banner is on:
Privatus Analytics sets no cookies and doesn't need to wait for consent. Confirm this
with your own legal assessment.
---
Source: https://privatusanalytics.com/docs/install/framer
# Add Privatus Analytics to Framer
> Add cookieless analytics to a Framer site through custom code, track single-page navigation automatically and count clicks with a data-privatus-event attribute.
1. Open your project and go to **Site settings → General → Custom code**.
2. Paste the snippet into **End of `` tag**:
```html
```
3. **Publish**.
Framer sites are single-page apps: page changes use `history.pushState`,
which the tracker follows automatically.
Your `*.framer.app` preview domain isn't one of your site's domains, so its
hits are dropped unless you add it.
## Events
Add a custom attribute in Framer (**Link → Custom attributes**, or through
a code override) named `data-privatus-event` to track clicks without code.
---
Source: https://privatusanalytics.com/docs/install/ghost
# Add Privatus Analytics to Ghost
> Add privacy-friendly analytics to a Ghost site through code injection, and count member and newsletter signups as an event without sending email addresses.
1. In Ghost Admin go to **Settings → Code injection**.
2. Paste the snippet into **Site header**:
```html
```
3. Save.
## Members and newsletter signups
Ghost's signup forms use `data-members-form`. To count signups as an event,
add this after the snippet:
```html
```
Never send the email address as a property. The
[PII scrubber](/docs/privacy/pii-scrubbing) would redact it anyway.
---
Source: https://privatusanalytics.com/docs/install/google-tag-manager
# Install Privatus Analytics with Google Tag Manager
> Load the Privatus Analytics tracker from a Google Tag Manager Custom HTML tag, set consent settings, send events from GTM triggers and avoid double counting.
If your site already loads tags through Google Tag Manager, you can load
Privatus Analytics from it. Adding the snippet directly to your pages is still more
reliable: GTM itself is blocked by many ad blockers.
## Custom HTML tag
1. In your container go to **Tags → New → Tag configuration → Custom HTML**.
2. Paste:
```html
```
3. Trigger: **Initialization - All Pages** (or **All Pages**).
4. Save, preview, and **Submit** to publish.
GTM copies the `data-` attributes onto the script it inserts, so every
[attribute](/docs/tracker/attributes) works as usual.
## Consent settings
Privatus Analytics sets no cookies and reads nothing from the device, so in GTM's
**Consent settings** for the tag you can choose **No additional consent
required** if that matches your legal assessment. See
[Privacy & compliance](/docs/privacy).
## Sending events from GTM
Create more Custom HTML tags that call the JavaScript API, fired by your
GTM triggers:
```html
```
`{{Form ID}}` here is a GTM variable. Only send values that contain no
personal data.
## Single-page apps
Don't fire the tag again on "History change" triggers. The tracker already
follows `history.pushState` navigations, so firing it twice double counts. See
[SPA double counting](/docs/troubleshooting/spa-double-counting).
---
Source: https://privatusanalytics.com/docs/install/cloudflare-zaraz
# Load Privatus Analytics with Cloudflare Zaraz
> Load the Privatus Analytics tracker through Cloudflare Zaraz with a Custom HTML tool, handle Zaraz single-page app support and send events from your site.
Zaraz loads third-party tools from Cloudflare's edge. Use a **Custom HTML**
tool to load the Privatus Analytics tracker.
1. In the Cloudflare dashboard open your zone and go to **Zaraz → Tools
configuration → Add new tool → Custom HTML**.
2. Name it "Privatus Analytics" and paste:
```html
```
3. Firing trigger: **Pageview**.
4. Save and publish.
Zaraz's "HTML" tools inject the script into the page, so it runs in the
visitor's browser like the normal snippet and supports every attribute.
Single-page app setting: if **Single Page Application support** is on in
Zaraz, it re-fires Pageview triggers on route changes. The tracker already
tracks those itself and ignores a second pageview for the same path, but
you should still use the tracker's SPA handling only: exclude the tool from
SPA re-triggering or keep Zaraz's SPA support off.
## Events
Call the JavaScript API from other Zaraz tools or from your site with
`privatus.track(…)`. Zaraz's own `zaraz.track()` doesn't forward to
Privatus Analytics.
---
Source: https://privatusanalytics.com/docs/install/nextjs
# Add Privatus Analytics to Next.js
> Add cookieless analytics to Next.js with @privatus/next or next/script, for the App or Pages Router, with server-side events.
## With `@privatus/next` (recommended)
> **Warning:** `@privatus/next` is not published on npm yet. Until it is,
> don't install a package with this name from the public registry,
> because anyone could have published it. Use the setup under
> [Without the package](#without-the-package) instead.
```sh
npm install @privatus/next
```
### 1. Add the component
App Router, in `app/layout.tsx`:
```tsx
import { PrivatusAnalytics } from '@privatus/next'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
```
Pages Router: render the same component in `pages/_app.tsx`.
The component takes every tracker option as a typed prop (`spa`, `manual`,
`modules`, `domains`, `exclude`, `mask`, `params`, `canonical`, `hash`…).
It loads the script from `privatusanalytics.com` and sends hits there.
### 2. Track events
```tsx
'use client'
import { usePrivatus } from '@privatus/next'
export function UpgradeButton() {
const privatus = usePrivatus()
return privatus.track('Upgrade', { plan: 'pro' })}>Upgrade
}
```
`trackAsync(name, props, timeout)` returns a promise that resolves when the
hit is sent (or after the timeout), which is handy before a redirect.
## Server-side events
Track from route handlers, server actions or middleware with a secret
[ingest key](/docs/server-side/ingest-keys) in `PRIVATUS_INGEST_KEY`:
```ts
// app/api/signup/route.ts
import { trackEvent } from '@privatus/next/server'
export async function POST(request: Request) {
// … create the account …
await trackEvent(request, { name: 'Signup', props: { plan: 'pro' } })
return Response.json({ ok: true })
}
```
The helper forwards the visitor's IP and User-Agent from the request in the
body (used in memory only, never stored) and works in the Edge and Node.js
runtimes.
## Without the package
Use `next/script` in the root layout:
```tsx
import Script from 'next/script'
```
Next.js client navigations use `history.pushState`, which the tracker
follows with its default `data-spa="auto"`.
---
Source: https://privatusanalytics.com/docs/install/nuxt
# Add Privatus Analytics to Nuxt
> Add privacy-friendly analytics to a Nuxt app with the @privatus/nuxt module or a head script in nuxt.config, with route changes tracked automatically.
## With `@privatus/nuxt`
> **Warning:** `@privatus/nuxt` is not published on npm yet. Until it is,
> don't install a package with this name from the public registry,
> because anyone could have published it. Use the setup under
> [Without the package](#without-the-package) instead.
```sh
npm install @privatus/nuxt
```
```ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@privatus/nuxt'],
privatus: {
site: 'pa_YOURSITEID',
modules: ['engage', 'auto'],
},
})
```
The module server-renders the tracker into `` and auto-imports
`usePrivatus()`:
```vue
Download
```
## Without the package
```ts
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
script: [
{ src: 'https://privatusanalytics.com/js/pa.js', defer: true, 'data-site': 'pa_YOURSITEID' },
],
},
},
})
```
Vue Router uses `history.pushState`, so route changes are tracked
automatically.
---
Source: https://privatusanalytics.com/docs/install/react
# Add Privatus Analytics to a React app
> Add cookieless analytics to a React single-page app built with Vite or Create React App, using the index.html snippet or the typed @privatus/tracker client.
## Simplest: the snippet in `index.html`
For Vite or Create React App, add the snippet to `index.html`:
```html
```
React Router (and most routers) navigate with `history.pushState`, which the
tracker follows automatically. With a hash router (`#/page`), add
`data-spa="hash"`.
## With `@privatus/tracker`
> **Warning:** `@privatus/tracker` is not published on npm yet. Until it is,
> don't install a package with this name from the public registry,
> because anyone could have published it. Use the snippet in
> `index.html` above instead.
The typed loader works with any framework:
```sh
npm install @privatus/tracker
```
```tsx
// main.tsx
import { init } from '@privatus/tracker'
init({ site: 'pa_YOURSITEID', modules: ['engage', 'auto'] })
```
```tsx
import { getClient } from '@privatus/tracker'
const privatus = getClient()
export function SignupButton() {
return privatus.track('Signup click')}>Sign up
}
```
`init()` installs the [queue stub](/docs/tracker/javascript-api#queue-stub)
and injects `pa.js` once. Every function is a no-op during server-side
rendering. The client also offers `trackAsync()`, `isLoaded()`, `props()`,
`optOut()`, `optIn()` and `isOptedOut()`.
For Next.js, use the [Next.js guide](/docs/install/nextjs). For Remix, use the
[Remix guide](/docs/install/remix).
---
Source: https://privatusanalytics.com/docs/install/vue
# Add Privatus Analytics to a Vue app
> Add privacy-friendly analytics to a Vue 3 app with a snippet in index.html, track Vue Router history or hash navigation and send custom events from components.
Add the snippet to `index.html`:
```html
```
Vue Router's `createWebHistory()` uses `history.pushState`, which the
tracker follows automatically. With `createWebHashHistory()`, add
`data-spa="hash"` so hash changes count as pageviews.
## Events
```vue
```
Or use the typed client from `@privatus/tracker` (`init()` and
`getClient()`), as shown in the [React guide](/docs/install/react). For
Nuxt, see the [Nuxt guide](/docs/install/nuxt).
> **Warning:** `@privatus/tracker` is not published on npm yet. Until it is,
> don't install a package with this name from the public registry,
> because anyone could have published it. Use the snippet above
> instead.
---
Source: https://privatusanalytics.com/docs/install/sveltekit
# Add Privatus Analytics to SvelteKit
> Add cookieless analytics to a SvelteKit app in src/app.html, track client-side navigation automatically and send client and server-side events from actions.
Add the snippet to `src/app.html`, inside `` before `%sveltekit.head%`:
```html
%sveltekit.head%
%sveltekit.body%
```
SvelteKit's client-side navigation uses `history.pushState`, which the
tracker follows automatically. Preloading data on hover doesn't send
pageviews.
## Events
```svelte
Subscribe
```
## Server-side events
In `+page.server.js` actions or `+server.js` endpoints, use the
[server-side ingest API](/docs/server-side) with your secret ingest key.
Forward the visitor's User-Agent and IP (`event.getClientAddress()`).
---
Source: https://privatusanalytics.com/docs/install/astro
# Add Privatus Analytics to Astro
> Add privacy-friendly analytics to an Astro site with the @privatus/astro integration or an is:inline script in your layout, with view transitions supported.
## With `@privatus/astro`
> **Warning:** `@privatus/astro` is not published on npm yet. Until it is,
> don't install a package with this name from the public registry,
> because anyone could have published it. Use the setup under
> [Without the package](#without-the-package) instead.
```sh
npm install @privatus/astro
```
```js
// astro.config.mjs
import { defineConfig } from 'astro/config'
import privatus from '@privatus/astro'
export default defineConfig({
integrations: [privatus({ site: 'pa_YOURSITEID' })],
})
```
The integration adds a tiny loader to every page's `` that installs
the queue stub and injects `pa.js` once. It works with static and SSR
output and with view transitions.
## Without the package
Add the snippet to your base layout's ``:
```astro
---
// src/layouts/Base.astro
---
```
`is:inline` stops Astro from bundling the script, which would break the
`data-` attributes.
With ` ` / ` `, navigations use
`history.pushState`, which the tracker follows. Don't add
`data-astro-rerun` to the tracker script: it would load the tracker again on
every navigation.
---
Source: https://privatusanalytics.com/docs/install/remix
# Add Privatus Analytics to Remix
> Add cookieless analytics to Remix or React Router 7 framework mode in app/root.tsx, and track conversions from actions with the server-side ingest API.
Add the snippet to the `` in `app/root.tsx`:
```tsx
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react'
export default function App() {
return (
)
}
```
Client navigations use `history.pushState`, which the tracker follows
automatically.
## Server-side events
Track conversions in an `action` with the
[server-side API](/docs/server-side), forwarding the request's User-Agent
and IP:
```ts
export async function action({ request }: ActionFunctionArgs) {
// … handle the form …
await fetch('https://privatusanalytics.com/api/events', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PRIVATUS_INGEST_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
events: [{
type: 'event', name: 'Signup', url: request.url,
user_agent: request.headers.get('user-agent'),
ip: request.headers.get('x-forwarded-for')?.split(',')[0],
}],
}),
})
return redirect('/welcome')
}
```
---
Source: https://privatusanalytics.com/docs/install/angular
# Add Privatus Analytics to Angular
> Add privacy-friendly analytics to an Angular app in src/index.html, handle hash location routing and track custom events from an injectable Angular service.
Add the snippet to `src/index.html` inside ``:
```html
```
The Angular router uses `history.pushState` by default, which the tracker
follows. With `withHashLocation()` (or `useHash: true`), add
`data-spa="hash"`.
## Events
Declare the global once and call it from a service:
```ts
// privatus.service.ts
import { Injectable } from '@angular/core'
declare global {
interface Window { privatus?: { track(name: string, props?: Record): void } }
}
@Injectable({ providedIn: 'root' })
export class PrivatusService {
track(name: string, props: Record = {}) {
window.privatus?.track(name, props)
}
}
```
With server-side rendering (Angular Universal / SSR), the snippet in
`index.html` still only runs in the browser, and the service's optional
call is a no-op on the server.
---
Source: https://privatusanalytics.com/docs/install/hugo
# Add Privatus Analytics to Hugo
> Add cookieless analytics to a Hugo site with a head partial that loads the tracker only in production builds, and keep your site id in the Hugo config.
Create (or edit) a partial your theme includes in ``, for example
`layouts/partials/extend_head.html` or `layouts/partials/head.html`:
```go-html-template
{{ if hugo.IsProduction }}
{{ end }}
```
`hugo.IsProduction` is true for `hugo` builds and false for `hugo server`,
so local previews aren't counted.
Keep the site id in your config instead:
```toml
# hugo.toml
[params]
privatusSite = "pa_YOURSITEID"
```
```go-html-template
{{ with site.Params.privatusSite }}
{{ end }}
```
---
Source: https://privatusanalytics.com/docs/install/jekyll
# Add Privatus Analytics to Jekyll
> Add cookieless analytics to a Jekyll site in your head include, load it only in production builds and keep the site id in _config.yml, on GitHub Pages too.
Add this to `_includes/head.html` (or your default layout's ``):
```liquid
{% if jekyll.environment == "production" %}
{% endif %}
```
And the site id to `_config.yml`:
```yaml
privatus_site: pa_YOURSITEID
```
Build with `JEKYLL_ENV=production bundle exec jekyll build`. GitHub Pages
sets the production environment for you.
With the Minima theme, override `_includes/custom-head.html` instead of
copying the whole head include.
---
Source: https://privatusanalytics.com/docs/install/eleventy
# Add Privatus Analytics to Eleventy (11ty)
> Add cookieless analytics to an Eleventy (11ty) base layout, with a global data file so the tracker loads in production builds but not in eleventy --serve.
In your base layout, e.g. `_includes/base.njk`:
```njk
{{ title }}
{% if env.production %}
{% endif %}
```
Expose `env.production` with a global data file:
```js
// _data/env.js
export default { production: process.env.ELEVENTY_RUN_MODE === 'build' }
```
`eleventy --serve` then leaves the tracker out, and production builds
include it.
---
Source: https://privatusanalytics.com/docs/install/docusaurus
# Add Privatus Analytics to Docusaurus
> Add privacy-friendly analytics to a Docusaurus docs site with the scripts config in docusaurus.config.js, and track docs feedback as custom events.
Add the script in `docusaurus.config.js`:
```js
export default {
// …
scripts: [
{
src: 'https://privatusanalytics.com/js/pa.js',
defer: true,
'data-site': 'pa_YOURSITEID',
},
],
}
```
Docusaurus is a single-page app after the first load. The tracker follows
its `history.pushState` navigations automatically.
## Track searches and feedback
Docs sites often want to know what people search for and whether a page
helped. Track those as events (never the raw text of free-form input if it
could contain personal data):
```js
window.privatus?.track('Docs feedback', { helpful: true })
```
---
Source: https://privatusanalytics.com/docs/install/amp
# Track AMP pages with Privatus Analytics
> Count pageviews on AMP pages with amp-analytics and the Privatus Analytics pixel endpoint, keep the referrer and report pages under your own domain.
AMP pages can't run custom JavaScript, so the tracker doesn't load there.
Use `amp-analytics` to call the [pixel endpoint](/docs/install/pixel)
instead.
Add the component script in ``:
```html
```
And this in ``:
```html
```
- `u=${canonicalUrl}` reports the page under your own domain, so AMP cache
domains (`cdn.ampproject.org`) don't matter.
- `r=${documentReferrer}` keeps the traffic source.
AMP pageviews use the no-JavaScript collection path, so there's no time on
page, scroll depth or events (see
[pixel limitations](/docs/install/pixel#limitations)).
---
Source: https://privatusanalytics.com/docs/install/pixel
# No-JavaScript tracking pixel
> Count pageviews without JavaScript using a 1×1 tracking pixel, with its parameters for page URL and referrer and its limits: pageviews only, no events.
`GET /api/pixel` records a pageview and returns a transparent 1×1 GIF.
```html
```
## Parameters
| Parameter | Required | Meaning |
|---|---|---|
| `s` | Yes | Site id (`pa_…`) |
| `u` | No | Full page URL. Defaults to the request's `Referer` header, which is why the example sets `referrerpolicy` so the browser sends the full URL |
| `r` | No | The page's own referrer (where the visitor came from). URL-encode it |
If your pages are rendered on the server, fill `u` and `r` in yourself:
```erb
```
Query parameters in `u` are minimized exactly like tracker hits: only UTM
parameters, `ref`, and your allowlisted parameters are kept.
## Limitations
- **Pageviews only.** No events, time on page or scroll depth.
- **Bots and proxies.** Image requests from email clients' image proxies and
from data centers are filtered as bots. Don't use the pixel to measure
email opens.
- Visits from the pixel and the JavaScript tracker are counted separately
only if both run. Inside `` they never do.
---
Source: https://privatusanalytics.com/docs/install/electron-and-extensions
# Analytics for Electron apps and browser extensions
> Count screens and events in Electron apps and browser extensions by posting to the collection endpoint with virtual URLs, without creating an identifier.
Apps and extensions don't have normal `https://` page URLs, so the browser
snippet isn't the right fit. Send hits to the public collection endpoint
yourself, using **virtual URLs on your site's domain**.
## The collection endpoint
`POST https://privatusanalytics.com/api/event` with a `text/plain` body (no CORS
preflight, no credentials):
```js
function privatusHit(type, path, extra = {}) {
return fetch('https://privatusanalytics.com/api/event', {
method: 'POST',
keepalive: true,
credentials: 'omit',
headers: { 'Content-Type': 'text/plain' },
body: JSON.stringify({
s: 'pa_YOURSITEID',
t: type, // 'pageview' or 'event'
u: 'https://app.example.com' + path, // a hostname that's one of your site's domains
l: navigator.language,
w: screen.width,
...extra, // n: event name, p: properties
}),
})
}
privatusHit('pageview', '/settings')
privatusHit('event', '/settings', { n: 'Theme changed', p: { theme: 'dark' } })
```
The short keys are documented in [What the tracker sends](/docs/tracker/payload).
## Electron
- Send hits from the renderer (so the request carries a normal browser
User-Agent), for example on each route change of your app's router.
- Electron's default User-Agent contains `Electron/…`, which our bot
filter treats as automation. Set a clean User-Agent for your window's
session, for example with `session.defaultSession.setUserAgent()`
removing the `Electron/x.y.z` token, or hits will be filtered as bots.
- Add `app.example.com` (or whatever host you use in `u`) to the site's
domains.
## Browser extensions
- Send hits from the extension's pages (popup, options page) or background
service worker. Add `https://privatusanalytics.com/*` to `host_permissions` in
`manifest.json` for Manifest V3.
- Describe the analytics in your store listing and privacy disclosures.
Chrome Web Store and Firefox Add-ons require it even for anonymous usage
data.
- Consider an in-extension opt-out that simply stops calling
`privatusHit()`.
## Privacy notes
Nothing here creates an identifier: visits are built exactly as for
websites (a daily key from the IP and User-Agent, which is never
stored). Don't put user ids, emails or file names in paths or
properties.
---
Source: https://privatusanalytics.com/docs/tracker
# Cookieless tracker reference (pa.js)
> How the cookieless pa.js tracker works and when it sends nothing, with links to every script attribute, the JavaScript API, modules, SPA support and debugging.
`pa.js` is the browser tracker. It's open source (MIT), a few kilobytes,
and it:
- sends **one small request per pageview or event** to
`https://privatusanalytics.com/api/event`, and
[retries it](/docs/tracker/payload#retries) a few times if the server
answers with an error,
- uses **no cookies, `localStorage`, `sessionStorage`, IndexedDB or
fingerprinting APIs** (canvas, WebGL, fonts, audio, battery). The one
exception is the [opt-out flag](/docs/tracker/opt-out) a visitor sets
themselves,
- makes **no request to any third party**.
```html
```
## Reference
- [Script attributes](/docs/tracker/attributes): every `data-` option.
- [JavaScript API](/docs/tracker/javascript-api): `track`, `pageview`,
`props`, `optOut`, `ready` and the queue stub.
- [Modules](/docs/tracker/modules): `engage`, `auto`, `vitals`, `clicks`.
- [Single-page apps](/docs/tracker/spa) and [manual mode](/docs/tracker/manual-mode).
- [Exclusions, masking and URLs](/docs/tracker/exclusions-and-masking):
domains, excluded paths, masks, query parameters, canonical URLs, hash
routing.
- [Debug mode](/docs/tracker/debug), [CSP](/docs/tracker/csp),
[versioning and SRI](/docs/tracker/versioning-and-sri),
[opt-out](/docs/tracker/opt-out).
- [What the tracker sends](/docs/tracker/payload): the exact request
body.
## When the tracker sends nothing
The tracker stays silent (and says why in the console with
`data-debug="true"`) when:
| Reason | Details |
|---|---|
| `missing data-site` | The script tag has no `data-site` |
| `opted out` | The visitor [opted out](/docs/tracker/opt-out) in this browser |
| `localhost` | The page is on `localhost`, `127.*`, `[::1]`, `0.0.0.0`, a `.local` host or `file://`, and neither `data-debug` nor `data-allow-local` is set |
| `domain not in data-domains` | You set `data-domains` and this hostname isn't in it |
| `automated browser` | `navigator.webdriver` is set, or PhantomJS, Nightmare or Cypress is detected |
| excluded path | The path matches `data-exclude` |
It also waits for a prerendered page to become visible before sending
anything (`prerenderingchange`), and sends a fresh pageview when a page is
restored from the back/forward cache.
## Open source
The tracker source, tests and build are public. Every file starts with a
comment naming Privatus Analytics: we don't disguise the script or offer
ways to hide it from blockers. See
[Ad blockers](/docs/troubleshooting/ad-blockers).
---
Source: https://privatusanalytics.com/docs/tracker/attributes
# Tracker script attributes reference
> Every data- attribute the pa.js tracker reads from its script tag, with defaults and examples for modules, SPA mode, domains, exclusions and masks.
Attributes are read once, from the `
```
A hash-routed app that adds the plan to every hit:
```html
```
## Not an attribute: Do Not Track and GPC
The tracker always reports whether the browser sends Do Not Track or
Global Privacy Control. Whether those hits are dropped is a site setting
(**Site settings → Privacy**): GPC is honoured by default, DNT is not. See
[DNT and GPC](/docs/privacy/dnt-gpc).
---
Source: https://privatusanalytics.com/docs/tracker/javascript-api
# Tracker JavaScript API reference
> Tracker JavaScript API reference: track custom events, send pageviews, add properties to every hit, opt out, use ready() and queue calls before pa.js loads.
Once loaded, the tracker exposes `window.privatus` (or the name in
[`data-namespace`](/docs/tracker/attributes)).
## `privatus.track(name, props?, callback?)`
Sends a custom event.
```js
privatus.track('Signup', { plan: 'pro', seats: 3 })
privatus.track('Purchase', { revenue: 49.99, currency: 'EUR', sku: 'A1' })
```
- `name`: up to 120 characters, any language. Trimmed and Unicode
normalised (NFC). Case is kept.
- `props`: up to 30 properties of type string (≤ 500 characters), number,
boolean or array of strings. `revenue` and `currency` are reserved. See
[Properties](/docs/events/properties) and [Revenue](/docs/events/revenue).
- `callback`: called once the request has completed, or straight away if
nothing is sent (opted out, excluded, debug mode). It's called after the
first attempt and never waits for a
[retry](/docs/tracker/payload#retries). The tracker adds no timeout of
its own, so add one when you navigate away:
```js
link.addEventListener('click', (e) => {
e.preventDefault()
let done = false
const go = () => { if (!done) { done = true; location.href = link.href } }
privatus.track('Outbound click', { domain: link.hostname }, go)
setTimeout(go, 300)
})
```
Requests are sent with `keepalive`, so in most cases you don't need to
wait at all: the hit survives the navigation.
You can pass the callback as the second argument when there are no
properties: `privatus.track('Logout', done)`.
## `privatus.pageview(options?)`
Sends a pageview for the **current URL**, even if the path hasn't changed.
```js
privatus.pageview()
privatus.pageview({ props: { variant: 'b' } })
```
- `options.props`: properties for this pageview only.
- The URL is always `location.href` (after exclusions, masks, canonical and
hash handling). To record a different path, change the URL first with
`history.pushState`, or use a [mask](/docs/tracker/exclusions-and-masking).
Use it with [manual mode](/docs/tracker/manual-mode) or for virtual
pageviews such as steps of a wizard that change the URL.
## `privatus.props(object)`
Adds properties to every later pageview and event on this page load.
Calls merge. Set a key to `null` to stop sending it.
```js
privatus.props({ theme: 'dark', logged_in: true })
```
Properties live in memory only and are gone on the next full page load.
## `privatus.optOut()`, `privatus.optIn()`, `privatus.isOptedOut()`
Stop or resume tracking in this browser. See [Opt-out](/docs/tracker/opt-out).
## `privatus.ready(fn)`
Runs `fn` once the tracker has loaded. Most useful with the queue stub,
where the call is queued until `pa.js` arrives.
```js
privatus.ready(() => console.log('Privatus Analytics is loaded'))
```
## Queue stub
`pa.js` loads with `defer`, so it isn't available to inline scripts that
run earlier. Put this stub **before** the tracker tag. Calls made before
the tracker loads are queued and replayed in order.
```html
```
The queue format is `window..q = [[method, [args…]], …]`.
`isOptedOut()` returns a value, so it can't be queued. Call it after
`ready()`.
If you use a custom `data-namespace`, use the same name in the stub.
## TypeScript
The npm package `@privatus/tracker` ships types and a loader (`init()`,
`getClient()`) that installs the stub for you. See the
[React guide](/docs/install/react).
> **Warning:** `@privatus/tracker` is not published on npm yet. Until it is,
> don't install a package with this name from the public registry,
> because anyone could have published it. Use the type declarations
> below instead.
```ts
declare global {
interface Window {
privatus?: {
track(name: string, props?: Record, callback?: () => void): void
pageview(options?: { props?: Record }): void
props(props: Record): void
optOut(): void
optIn(): void
isOptedOut(): boolean
ready(fn: () => void): void
}
}
}
```
---
Source: https://privatusanalytics.com/docs/tracker/modules
# Optional tracker modules
> Load optional tracker modules for time on page and scroll depth, automatic outbound link, download, form and 404 events, Web Vitals and aggregate click maps.
Modules are small separate files the tracker loads on demand, from the same
origin as the tracker (or [`data-api`](/docs/tracker/attributes)). Choose
them with `data-modules`:
```html
```
| Module | Default | What it adds |
|---|---|---|
| `engage` | On | Time on page (visible time only) and scroll depth |
| `auto` | Off | Outbound links, file downloads, `mailto:`/`tel:` links, form submits and 404s as events |
| `vitals` | Off | Web Vitals: LCP, INP, CLS, FCP, TTFB |
| `clicks` | Off | Aggregate click counts per element for click maps |
> **Warning:** `data-modules` replaces the default. `data-modules="auto"`
> turns `engage` off. Write `data-modules="engage,auto"` to keep both.
## `engage`: time on page and scroll depth
Built into `pa.js` (no extra file). It measures:
- **Engaged time**: only while the tab is visible. Switching tabs or
minimizing pauses the clock.
- **Scroll depth**: the deepest point reached, as a percentage of the
scrollable height, stored in 10% bands.
It sends an `engagement` hit when the page is hidden or closed, and before
the next SPA pageview, if at least one second of visible time has passed.
Engagement hits aren't pageviews or events: they don't count toward your
[usage](/docs/billing/usage).
Without `engage`, time on page, visit duration and the "under 10 seconds"
part of [bounce rate](/docs/metrics) can't be measured.
## `auto`: automatic events
Loads `pa.auto.js` and tracks these events:
| Event name | When | Properties |
|---|---|---|
| `Outbound Link` | A click on a link to another host | `domain` (without `www.`), `url` (origin and path, no query) |
| `File Download` | A click on a link to a file (pdf, zip, dmg, exe, csv, xlsx, docx, pptx, mp3, mp4, iso, json, svg and more) | `file` (file name), `host` |
| `Email Link` | A click on a `mailto:` link | `to_domain` (the part after `@`, never the address) |
| `Phone Link` | A click on a `tel:` link | none (the number isn't sent) |
| `Form Submit` | Any form is submitted | `form`: the form's `id`, `name` or `action` |
| `404` | The page's `` contains "404" or "not found", or the page has ` ` | `path` |
Elements with a [`data-privatus-event`](/docs/events/html-attributes)
attribute are skipped by `auto`, so you never count one click twice.
For a reliable 404 event on pages whose title doesn't say "not found",
add the meta tag to your 404 template:
```html
```
Create [goals](/docs/features/goals) of type *Outbound click* or *File
download* to turn these into conversions.
## `vitals`: Web Vitals
Loads `pa.vitals.js`, which uses `PerformanceObserver` to measure:
| Metric | Unit |
|---|---|
| `LCP` Largest Contentful Paint | milliseconds |
| `INP` Interaction to Next Paint (the slowest interaction) | milliseconds |
| `CLS` Cumulative Layout Shift | unitless, 3 decimals |
| `FCP` First Contentful Paint | milliseconds |
| `TTFB` Time to First Byte | milliseconds |
Values are sent once, when the page is first hidden. Web Vitals hits are
**sampled** on the server at the site's sample rate (**Site settings →
Tracking**, where the Free plan allows up to 10%) and **never count toward
usage**. Results are on the [Performance](/docs/features/performance) page.
## `clicks`: click maps
Loads `pa.clicks.js`, which counts clicks per element for aggregate
[click maps](/docs/features/click-maps). For each click it sends a short CSS
selector (up to 4 levels, e.g. `nav.main > a#pricing`) of the nearest link,
button, input, label, `summary`, `[role=button]` or element with
`data-privatus-click`. Ids and classes with three or more digits in a row
(usually generated) are left out.
It never sends mouse movement, coordinates, the text of the element or
anything typed. Clicks are throttled to one per 300 ms.
Click hits don't count toward your usage.
---
Source: https://privatusanalytics.com/docs/tracker/spa
# Track single-page apps (SPAs)
> How the tracker follows single-page app navigation with the History API or hash routing, what counts as a new page, and how to avoid counting pageviews twice.
Most single-page apps (React Router, Next.js, Vue Router, SvelteKit,
Angular, Astro view transitions…) change pages with `history.pushState`.
The tracker handles them with no configuration.
## Modes (`data-spa`)
| Value | Behavior |
|---|---|
| `auto` (default) | Wraps `history.pushState` and listens to `popstate` (back/forward). Each navigation to a new path sends a pageview |
| `history` | Same as `auto` |
| `hash` | Listens to `hashchange` and keeps the `#fragment` in the path (`/#/settings`). For routers that use `#/` URLs |
| `off` | Only the initial page load is tracked. Use the [JavaScript API](/docs/tracker/javascript-api) for the rest |
With `auto`, fragment changes aren't pageviews, and `history.replaceState`
isn't tracked (routers use it for things like updating query strings).
## What counts as a new page
A pageview is sent when the **path** changes: the pathname, plus the
fragment in hash mode. Changing only the query string (`?tab=2`) doesn't
send one, and neither does navigating to the same path again. Call
`privatus.pageview()` to force one.
For each SPA navigation:
1. The engagement time and scroll depth of the page you're leaving are
sent (with the `engage` module).
2. The referrer of the new pageview is a URL on your own site, so internal
navigation never shows up as a new traffic source. The visit keeps the
source it started with.
## Double counting
If you also call `privatus.pageview()` in your router's navigation hook,
or fire the tracker again from a tag manager on history changes, each
navigation is counted twice. Pick one: the built-in SPA tracking, or
[manual mode](/docs/tracker/manual-mode) with `data-spa="off"`. See
[SPA double counting](/docs/troubleshooting/spa-double-counting).
---
Source: https://privatusanalytics.com/docs/tracker/manual-mode
# Send pageviews manually (manual mode)
> Turn off the automatic pageview with data-manual and send pageviews yourself with privatus.pageview(), for example after reading an A/B test variant at runtime.
Add `data-manual="true"` to stop the automatic pageview when the page
loads:
```html
```
Then send pageviews when you decide:
```js
privatus.ready(() => {
if (!document.body.classList.contains('preview')) {
privatus.pageview({ props: { logged_in: isLoggedIn } })
}
})
```
Manual mode only turns off the **first** automatic pageview. SPA
navigation tracking still runs. Add `data-spa="off"` too if you want full
control:
```html
```
## When to use it
- You want to add properties to the first pageview that aren't known until
later (for example, after reading an A/B test variant).
- Some pages shouldn't count based on runtime logic (previews, staff
sessions).
- Your router needs pageviews at a moment the History API doesn't reflect
(after data loads, or on modal "pages" that change the URL with
`replaceState`).
`privatus.pageview()` always records the current `location.href`, so
update the URL before calling it.
---
Source: https://privatusanalytics.com/docs/tracker/exclusions-and-masking
# Exclude pages and mask URLs in the tracker
> Exclude pages and mask URLs in the browser: control which hostnames and paths are tracked and which URL is recorded, including query parameters.
Everything on this page happens **in the browser**, before anything is
sent. The server applies its own rules afterwards (site domains,
[traffic rules](/docs/privacy/traffic-rules), path masks, the query
parameter allowlist and the [PII scrubber](/docs/privacy/pii-scrubbing)).
## Only some hostnames: `data-domains`
```html
data-domains="example.com,www.example.com"
```
Nothing is sent on any other hostname: staging servers, preview
deployments, translation proxies, copies of your site. The match is exact, so
list each hostname you use.
The server also drops hits whose hostname isn't one of the site's domains
(or a subdomain of one), so `data-domains` is mostly useful when your
staging hosts are subdomains of your main domain.
## Skip paths: `data-exclude`
```html
data-exclude="/admin/*,/preview/*,/account/*"
```
Comma-separated globs matched against the path (no query string). `*`
matches any characters, including `/`, so `/admin/*` excludes everything
under `/admin/`. `/admin` itself needs its own entry.
Excluded pages send nothing at all: no pageview, no events.
## Rewrite paths: `data-mask`
Group URLs that contain ids into one page, and keep ids out of your data:
```html
data-mask="/users/*/settings->/users/:id/settings,/orders/*->/orders/:id"
```
Each rule is `glob->replacement`. The first matching rule replaces the
whole path. `*` matches any characters.
Prefer **server-side path masking** (Site settings → Privacy) when you
can: it applies to every collection method (tracker, pixel, server-side
API), and its `*` matches exactly one path segment. Client-side masks are
useful when the id itself must never leave the browser.
## Keep query parameters: `data-params`
By default the tracker keeps only campaign parameters (`utm_*`, `ref`,
`via`, `source`) and click ids, and drops everything else before sending.
```html
data-params="page,category"
```
keeps `page` and `category` too. The server stores a query parameter only
if it's also on the site's **query parameter allowlist** (Site settings →
Privacy), so add it there as well. Parameters are then part of the page
path in reports: `/search?category=shoes`.
## Canonical URLs: `data-canonical`
```html
data-canonical="true"
```
Reports the `href` of ` ` instead of the address bar
URL. Useful when the same content lives under several URLs (tracking
parameters, print views, pagination). Campaign parameters from the address
bar are lost when the canonical URL doesn't carry them.
## Hash routing: `data-hash`
```html
data-hash="true"
```
Keeps the `#fragment` in the path (`/#/settings`). `data-spa="hash"`
turns this on and also tracks `hashchange` navigations. Without it, the
fragment is always dropped.
---
Source: https://privatusanalytics.com/docs/tracker/debug
# Tracker debug mode
> Use tracker debug mode to log every hit to the console without sending it, send real hits from localhost, and check the /api/event request in your dev tools.
```html
```
With `data-debug="true"` the tracker:
- **logs every hit** to the browser console as `[privatus] pageview {…}`,
with the exact request body,
- **doesn't send anything**, so debugging never pollutes your data,
- runs on `localhost` and `file://`,
- logs why a hit wasn't sent (`[privatus] not sent: opted out`, `…:
domain not in data-domains`, and so on).
## Sending from a local server
To send real hits while developing (for example, to test goals end to end
on a development site), use `data-allow-local="true"` instead. The site's
domains must include the hostname you use, e.g. `localhost`, or the server
drops the hits. Most people create a separate "development" site for this.
## Checking the network request
In the browser's developer tools, **Network** tab, filter for `event`.
Each hit is a `POST` to `/api/event` answered with **202 Accepted**. See
[What the tracker sends](/docs/tracker/payload) for the body.
---
Source: https://privatusanalytics.com/docs/tracker/csp
# Content Security Policy for the tracker
> The Content Security Policy directives the analytics tracker needs (script-src, connect-src and img-src), plus the setup for nonces.
If your site sends a `Content-Security-Policy` header, allow the tracker's
script and its requests:
```http
Content-Security-Policy: script-src 'self' https://privatusanalytics.com; connect-src 'self' https://privatusanalytics.com
```
| Directive | Why |
|---|---|
| `script-src https://privatusanalytics.com` | Loads `pa.js` and any [modules](/docs/tracker/modules) (`pa.auto.js`, `pa.vitals.js`, `pa.clicks.js`) |
| `connect-src https://privatusanalytics.com` | Sends hits with `fetch`/`sendBeacon` to `/api/event` |
| `img-src https://privatusanalytics.com` | Only for the [no-JavaScript pixel](/docs/install/pixel) |
Add these to your existing directives and keep everything else you already
allow.
## The queue stub and nonces
The [queue stub](/docs/tracker/javascript-api#queue-stub) is an inline
script. With a strict CSP, give it your page's nonce
(`
```
Modules are loaded from `data-api`, not from your copy's location, and
they are loaded without an `integrity` attribute. So with the setup above,
any module you add in `data-modules` (for example `vitals` or `clicks`)
comes from `https://privatusanalytics.com/js/` and is not covered by your SRI hash.
If your policy requires every script to be pinned, leave out
`data-modules` or set it to `engage` only (engagement tracking is built
into `pa.js` and loads no extra file).
Update your copy when you want new tracker features. Old versions keep
working.
---
Source: https://privatusanalytics.com/docs/tracker/opt-out
# Let visitors opt out of analytics
> Let visitors opt out of analytics with privatus.optOut(), the only flag the tracker stores, and exclude your own visits by browser, IP range or WordPress.
## For visitors
```js
privatus.optOut() // stop sending hits from this browser
privatus.optIn() // undo
privatus.isOptedOut() // true or false
```
`optOut()` stores `privatus_optout=1` in `localStorage` on **your** domain.
It's the only thing the tracker ever writes to the device, and only
because the visitor asked for it, which makes it strictly necessary under
the ePrivacy Directive (Art. 5(3)). While it's set, the tracker sends
nothing: no pageviews, events, engagement, Vitals or clicks. Hits that
were waiting for a retry are dropped too.
The flag is per browser and per domain (subdomains have separate storage).
If storage is blocked, `optOut()` does nothing and `isOptedOut()` returns
`false`.
A ready-made button for your privacy page is in
[Opt-out snippet](/docs/privacy/opt-out). The WordPress plugin provides a
`[privatus_optout]` shortcode.
Browsers that send [Global Privacy Control](/docs/privacy/dnt-gpc) are
excluded automatically when the site honours GPC (the default).
## For you and your team
- Run `privatus.optOut()` once in the browser console on each of your
sites and browsers.
- Or block your office or VPN IP ranges in
[traffic rules](/docs/privacy/traffic-rules). The IP is checked in
memory and never stored.
- WordPress: the plugin skips logged-in administrators by default.
---
Source: https://privatusanalytics.com/docs/tracker/payload
# What the tracker sends (request payload)
> The exact POST request the tracker sends to /api/event, every field in its JSON payload, and what is never sent: no cookies, identifiers or fingerprints.
Every hit is one `POST` to `{origin}/api/event`:
- `Content-Type: text/plain`, so browsers don't send a CORS preflight,
- `credentials: 'omit'`: no cookies are sent or accepted,
- `keepalive: true`, so hits survive page navigation. `navigator.sendBeacon`
is the fallback when `fetch` throws.
The server answers **202 Accepted** with an empty body, whether or not the
hit is later kept, so the response never tells a script anything about
your settings.
## Retries
If the server answers with an error (any `4xx` or `5xx` status, including
`429` when a rate limit is hit), the tracker sends the same hit again:
- up to 5 more times, after 1 second, 10 seconds, then 60 seconds for
each of the last three,
- oldest hit first, one at a time,
- from a queue of at most 20 hits that lives in memory only. Nothing is
written to the device, so hits still waiting are lost when the page is
closed or reloaded.
A request that never gets an answer (the visitor is offline, or a content
blocker stops it) isn't retried.
A retried hit is recorded with the time it arrives, which can be a few
minutes after it happened. In rare cases the server has already recorded
a hit before the error reaches the browser, and the retry counts it a
second time.
## Body
```json
{
"s": "pa_7Q2K9XH3AB",
"t": "pageview",
"u": "https://example.com/pricing?utm_source=newsletter",
"r": "https://www.google.com/",
"l": "en-US",
"w": 1440,
"i": 0,
"p": { "plan": "pro" }
}
```
| Key | Long name | Sent with | Meaning |
|---|---|---|---|
| `s` | `site` | all | Site id |
| `t` | `type` | all | `pageview`, `event`, `engagement`, `vital` or `click` |
| `u` | `url` | all | Page URL after exclusions, masks and query parameter stripping |
| `r` | `referrer` | when present | `document.referrer`, or a URL on your own site for SPA navigations |
| `l` | `language` | all | `navigator.language` |
| `w` | `screen_width` | all | `screen.width` in CSS pixels (stored as a size bucket) |
| `i` | `interacted` | all | `1` once a real input was seen on the page, `0` before. See [Interaction flag](#interaction-flag) |
| `n` | `name` | event, vital | Event name, or the Web Vital (`LCP`, `INP`, `CLS`, `FCP`, `TTFB`) |
| `p` | `props` | pageview, event | Properties |
| `e` | `engaged_ms` | engagement | Visible time on the page in milliseconds |
| `sd` | `scroll_depth` | engagement | Maximum scroll depth, 0 to 100 |
| `v` | `value` | vital | The Web Vital value |
| `c` | `selector` | click | Short CSS selector of the clicked element |
| `h` | `hash_mode` | when on | `1` if the fragment is part of the path |
| `d` | `dnt` | when on | `1` if Do Not Track is on |
| `g` | `gpc` | when on | `1` if Global Privacy Control is on |
The server also accepts `rv`/`revenue` and `cu`/`currency` at the top
level, but the tracker puts them in `p`.
Anything larger than 32 KB is ignored.
## Interaction flag
Some bots run a real browser, load a page and leave without touching it.
To tell them from people, every hit says whether the tracker has seen a
real input on the page:
- `i` is `0` until the first pointer press or move, key press, touch or
wheel turn, and `1` from then on.
- At that first input the tracker sends one `engagement` hit straight
away, so the visit is marked even if the hit sent when the page closes
is lost.
- Scrolling alone doesn't count, and neither does input a script
generates. Both are things a bot can do without a person.
It is a yes or no. Nothing about the input is sent: not what it was, not
where the pointer was, not which key, not when. Visits are then reported
as input seen, none or unknown, see
[Visits with no input seen](/docs/server-side/bots#visits-with-no-input-seen).
## What isn't sent
No cookies, no stored identifiers, no user id, no screen height or color
depth, no installed fonts or plugins, no canvas or audio fingerprints, no
timezone, no mouse movement, no form contents.
The server receives the IP address and User-Agent because every HTTP
request carries them. They're used in memory and dropped. See
[How it works](/docs/privacy).
---
Source: https://privatusanalytics.com/docs/events
# Custom events and properties
> Track signups, purchases and other custom events with privatus.track(), HTML attributes or your server, plus naming rules, limits and how events count as usage.
A **custom event** is something that happens on a page: a signup, a
purchase, a video play. Events have a name and optional properties.
```js
privatus.track('Signup', { plan: 'pro', seats: 3 })
```
Events appear in the Conversions panel on the Overview and in the
[Events explorer](/docs/dashboard/events), and can become
[goals](/docs/features/goals) and [funnel](/docs/features/funnels) steps.
Three ways to send them:
1. **JavaScript:** `privatus.track()` ([API](/docs/tracker/javascript-api)).
2. **HTML attributes, no code:** `data-privatus-event="Signup"`
([HTML attributes](/docs/events/html-attributes)).
3. **From your server:** the [server-side ingest API](/docs/server-side).
The [`auto` module](/docs/tracker/modules#auto-automatic-events) tracks
outbound links, downloads, forms and 404s for you.
## Naming
- Up to **120 characters**, any language and script. Longer names are cut.
- Leading and trailing spaces are trimmed and Unicode is normalised (NFC),
so `Café` typed two different ways is one event.
- Case is kept for display. Pick one style and stick to it:
`Signup` and `signup` are stored as written. You can merge names later in
**Site settings → Events & properties**.
- Name the action, not the element: `Signup`, `Checkout started`,
`Invoice downloaded`, rather than `Blue button click`.
- Don't put variable data in names (`Viewed product 123`). Use a
property.
## Limits
| Limit | Value |
|---|---|
| Event name | 120 characters |
| Properties per event | 30 (extra ones are dropped) |
| Property key | 64 characters, `a-z`, `0-9`, `_` |
| String value | 500 characters |
| Array value | up to 30 strings |
| Property keys per site | 10 on Free, unlimited on paid plans |
| Request body | 32 KB |
## Usage
Every custom event counts as one event toward your monthly allowance, like
a pageview. Engagement pings, Web Vitals, click-map hits and bots don't
count. See [What counts as an event](/docs/billing/usage).
## Managing events
In **Site settings → Events & properties** you can rename events (history
is rewritten at query time), merge several names into one, hide events,
and delete an event including its history. See
[Events explorer](/docs/dashboard/events).
---
Source: https://privatusanalytics.com/docs/events/properties
# Custom event properties
> Add typed custom event properties (strings, numbers, booleans and arrays) to events and pageviews, see how keys are normalized and query them with prop filters.
Properties are key-value pairs sent with an event or pageview:
```js
privatus.track('Download', { file_type: 'pdf', size_mb: 2.4, gated: false, tags: ['guide', 'pricing'] })
```
## Types
| Type | Example | Notes |
|---|---|---|
| String | `'pro'` | Up to 500 characters. Longer values are cut |
| Number | `49.99`, `3` | Must be finite. Sum, average, median and p90 are available in the Events explorer |
| Boolean | `true` | |
| Array of strings | `['a', 'b']` | Up to 30 items. Non-string items are dropped |
`null`, `undefined`, objects and nested arrays are dropped. Dates: send an
ISO 8601 string (`'2026-09-29'`).
## Keys
Keys are normalised before storage: lowercased, any character outside
`a-z 0-9 _` becomes `_`, repeated underscores are collapsed, and the key is
cut to 64 characters. `Plan Name` becomes `plan_name`, `userType` becomes
`usertype`. Use `snake_case` to avoid surprises.
## Reserved keys
`revenue` and `currency` are taken out of the properties and stored as the
event's [revenue](/docs/events/revenue).
## Properties on every hit
```js
privatus.props({ logged_in: true, theme: 'dark' })
```
adds properties to every later pageview and event on the page load. Or
point [`data-props`](/docs/tracker/attributes) at a function that returns
them.
## Privacy
The [PII scrubber](/docs/privacy/pii-scrubbing) runs on every string value:
emails, long ids, tokens and long digit runs are replaced with
`[redacted-…]`. It's a safety net, not a license: never send names, emails,
user ids or free text typed by visitors.
## Querying properties
Use `prop:` as a dimension anywhere the API or dashboard accepts one:
```text
/sites/pa_7Q2K9XH3AB/breakdown/prop:plan.json?filters=[["event","is","Signup"]]
```
or as a filter: `["prop:plan", "is", "pro"]`. See [Filters](/docs/api/filters).
---
Source: https://privatusanalytics.com/docs/events/revenue
# Revenue tracking
> Revenue tracking for custom events: attach an amount and ISO 4217 currency, report it in your site currency with ECB rates, and record refunds.
Add `revenue` (in major units: dollars, euros…) and an ISO 4217 `currency`
to any event:
```js
privatus.track('Purchase', { revenue: 49.99, currency: 'EUR', plan: 'pro' })
```
- `revenue` may be a number or a numeric string (`'49.99'`). It's stored
exactly, in minor units (cents).
- `currency` defaults to the site's currency when you leave it out.
- Both are removed from the properties and stored as dedicated fields.
## Multi-currency
Each event keeps its original currency and amount. Reports convert revenue
into the **site currency** using the European Central Bank reference rate
for the event's date (falling back to the latest earlier rate). You can
break revenue down by source, campaign, page or any property, in the site
currency.
## Revenue goals
A [goal](/docs/features/goals) can take its value from the event's
revenue, or have a fixed value (for example $50 per lead) when there's no
real revenue.
## Refunds
Send a negative amount with the same currency to record a refund as its
own event, for example `privatus.track('Refund', { revenue: -49.99, currency: 'EUR' })`,
or keep refunds out of analytics and treat revenue as gross.
## Server-side revenue
Purchases confirmed by a payment webhook are more reliable than
thank-you-page events. Send them with the [server-side API](/docs/server-side):
```json
{ "type": "event", "name": "Purchase", "url": "https://example.com/checkout", "revenue": 49.99, "currency": "EUR" }
```
---
Source: https://privatusanalytics.com/docs/events/html-attributes
# Track events with HTML attributes
> Track clicks and form submissions as custom events without writing JavaScript, using data-privatus-event and data-privatus-prop HTML attributes on any element.
Add `data-privatus-event` to any element to send an event when it's
clicked:
```html
See pricing
Play
```
## Properties
Every `data-privatus-prop-` attribute becomes a property. Dashes in the
key become underscores:
```html
Start free
```
sends `Signup CTA` with `{ plan: 'pro', button_location: 'hero' }`.
Attribute values are always strings.
## Forms
On a `
```
Form fields are never read or sent. Only the attributes you write become
properties.
## How it works
The tracker listens for clicks and submits on the whole document (in the
capture phase), finds the nearest ancestor with `data-privatus-event`, and
sends the event. It works for elements added later, too.
- Clicking a child element (an icon inside a button) counts for the
element with the attribute.
- Elements with `data-privatus-event` are ignored by the
[`auto` module](/docs/tracker/modules#auto-automatic-events), so a
tracked outbound link isn't counted twice.
- Links navigate as usual. The request uses `keepalive` so it survives the
navigation.
## Click maps
For aggregate click maps, the `clicks` module also recognizes
`data-privatus-click` as a named click target. See
[Modules](/docs/tracker/modules#clicks-click-maps).
---
Source: https://privatusanalytics.com/docs/events/recipes
# Event tracking recipes
> Copy-paste event tracking for signups, checkout funnels, file downloads, outbound links, 404 pages, scroll milestones, video, A/B tests and user plans.
## Signup
On the page after a successful signup (or in your signup success handler):
```js
privatus.track('Signup', { plan: 'free', method: 'email' })
```
Better still, send it from the server once the account exists, so blocked
scripts and double submits don't matter. See
[server-side examples](/docs/server-side/examples).
Then create an **Event** goal named `Signup`.
## Checkout funnel
```js
privatus.track('Checkout started', { items: cart.items.length })
// …
privatus.track('Payment details entered')
// on the confirmation page:
privatus.track('Purchase', { revenue: order.total, currency: order.currency, items: order.items.length })
```
Build a [funnel](/docs/features/funnels) with the three events as steps.
Never send order ids, emails or addresses.
## File downloads
Automatically, with the `auto` module (`File Download` events with a `file`
property):
```html
```
Or for a single link:
```html
Price list (PDF)
```
## Outbound links
The `auto` module sends `Outbound Link` with `domain` and `url`. Create an
**Outbound click** goal for a specific domain, e.g. your app's signup page
on another domain.
## 404 pages
With the `auto` module, add this to your 404 template:
```html
```
Then look at the `404` event's `path` property to find broken links, or
use the **Not found** tab of the Pages panel. Without `auto`:
```html
```
## Scroll milestones
The `engage` module already reports scroll depth for every page. For an
event at a specific point (e.g. the end of an article):
```js
const end = document.querySelector('#article-end')
new IntersectionObserver((entries, observer) => {
if (entries.some((e) => e.isIntersecting)) {
privatus.track('Article finished', { section: 'blog' })
observer.disconnect()
}
}).observe(end)
```
Or create an **Engagement** goal (scroll ≥ 75% on `/blog/*`) with no code.
## Video
HTML5 video:
```js
document.querySelectorAll('video').forEach((video) => {
const name = video.dataset.name || video.currentSrc.split('/').pop()
video.addEventListener('play', () => privatus.track('Video play', { video: name }), { once: true })
video.addEventListener('ended', () => privatus.track('Video complete', { video: name }))
})
```
YouTube embeds need the YouTube IFrame API: listen to `onStateChange` and
track `YT.PlayerState.PLAYING` and `ENDED` the same way.
## Experiments (A/B tests)
Send the variant as a property on every hit, so every metric can be split
by variant:
```js
privatus.props({ experiment: 'pricing_2026_09', variant: variant })
```
with [manual mode](/docs/tracker/manual-mode) if the variant isn't known
until after load. Then break down any goal by `prop:variant`. The
[Experiments report](/docs/features/experiments) calculates conversion
rates per variant and their statistical significance.
## Logged-in state or plan
```js
privatus.props({ logged_in: true, plan: 'business' })
```
Never send a user id: it would make visitors identifiable and defeat the
cookieless design.
---
Source: https://privatusanalytics.com/docs/server-side
# Server-side ingest API
> Send pageviews and custom events from your backend with the server-side ingest API: the endpoint, ingest key authentication, event fields, limits and responses.
Send events from your server when the browser can't or shouldn't: payment
webhooks, signups confirmed by email, API products, backend jobs, or
pages served to clients without JavaScript.
## Endpoint
```http
POST https://privatusanalytics.com/api/events
Authorization: Bearer pik_…
Content-Type: application/json
```
```json
{
"events": [
{
"type": "event",
"name": "Purchase",
"url": "https://example.com/checkout",
"revenue": 49.99,
"currency": "USD",
"props": { "plan": "pro" },
"user_agent": "Mozilla/5.0 (Macintosh; …) Safari/605.1.15",
"ip": "203.0.113.7"
}
]
}
```
Authenticate with the site's secret **ingest key** (`pik_…`), either as a
Bearer token or in an `X-Ingest-Key` header. Each key belongs to one site.
See [Ingest keys](/docs/server-side/ingest-keys).
## Event fields
| Field | Required | Description |
|---|---|---|
| `type` | Yes | `pageview` or `event` (`custom` is an alias). `engagement` and `vital` are also accepted but need an existing visit |
| `name` | For events | Event name, up to 120 characters |
| `url` | Yes | Full `http(s)` URL of the page. Its hostname must be one of the site's domains (or a subdomain of one) |
| `referrer` | No | Where the visitor came from, which drives sources and channels |
| `props` | No | Up to 30 [properties](/docs/events/properties) |
| `revenue` | No | Amount in major units, e.g. `49.99` ([Revenue](/docs/events/revenue)) |
| `currency` | No | ISO 4217 code. Defaults to the site currency |
| `timestamp` | No | ISO 8601. Must be within the last **72 hours**. Otherwise (or if missing or in the future) the time of receipt is used |
| `user_agent` | In practice, yes | The **visitor's** browser User-Agent. Missing or library User-Agents are dropped as bots (see below) |
| `ip` | No | The **visitor's** IP. Used in memory for the country and the daily visit key, then dropped. Without it there's no location |
| `session_hint` | No | An opaque string that groups your server events into one visit. See [Sessions](/docs/server-side/sessions) |
| `language` | No | e.g. `en-US` |
| `screen_width` | No | Pixels, stored as a size bucket |
The short keys the browser tracker uses (`t`, `n`, `u`, `r`, `p`…) are
accepted too. See [What the tracker sends](/docs/tracker/payload).
## Limits
- Up to **100 events** per request. More returns `422`.
- The request body must be at most **32 KB**. Larger bodies are read as
empty. Split big batches.
- Everything else (property limits, name length, PII scrubbing, path
masking, traffic rules, bot filtering) works exactly as for browser hits.
## Response
`202 Accepted` with one result per event, in order:
```json
{
"data": [
{ "index": 0, "status": "accepted", "reason": null },
{ "index": 1, "status": "dropped", "reason": "bot_automation" }
]
}
```
`accepted` means the event was queued and will appear in the dashboard
within seconds. `dropped` events aren't stored and don't count toward
usage. Reasons:
| Reason | Meaning |
|---|---|
| `bad_type` | `type` isn't one of the accepted values |
| `bad_url` | `url` is missing or not an `http(s)` URL |
| `foreign_host` | The URL's hostname isn't one of the site's domains |
| `privacy_signal` | The event carried DNT/GPC (`dnt`/`gpc` fields) and the site honours it |
| `bot_empty_user_agent` | No `user_agent` |
| `bot_known_bot` | A known crawler User-Agent |
| `bot_automation` | An automation or HTTP-library User-Agent (see [Bot rules](/docs/server-side/bots)) |
| `bot_datacenter` | The `ip` belongs to a cloud or hosting network |
| `bot_referrer_spam` | The referrer is on the referrer-spam list |
| `rule_` | Blocked by a [traffic rule](/docs/privacy/traffic-rules), e.g. `rule_paths` |
| `vital_sampled` | A Web Vital outside the sample rate |
| `no_visit` | An engagement or vital hit with no visit to attach to |
| `paused` | Collection is paused for the workspace by Privatus Analytics support |
| `limit_reached` | The workspace reached its monthly event limit, so pageviews and custom events are not recorded until the reset or an upgrade (see [Monthly limit](/docs/billing/overage)) |
Errors:
| Status | Body `error.code` | When |
|---|---|---|
| `401` | `unauthorized` | Missing or unknown ingest key |
| `422` | `validation_failed` | More than 100 events, or `events` isn't an array |
## Next
- [Examples](/docs/server-side/examples) in curl, Node, Python, PHP, Go and
Ruby.
- [SDKs](/docs/server-side/sdks) that batch, retry and handle timestamps
for you.
- The browser's [no-JavaScript pixel](/docs/install/pixel) for pages
without JavaScript.
---
Source: https://privatusanalytics.com/docs/server-side/ingest-keys
# Server-side ingest keys
> Create, rotate and revoke the secret ingest key that authenticates server-side events, keep it out of browser code, and see how it differs from an API token.
An ingest key (`pik_…`) lets a server send events for **one site**. Unlike
the public site id, it's a **secret**: anyone with it can send data to
your site.
## Create or rotate
**Site settings → Tracking → Server ingest key → Create key.** The key is
shown **once**. Copy it into your secret store (environment variable,
secrets manager). We only keep a SHA-256 digest and a short prefix so you
can recognize it.
Creating a key again **rotates** it: the new key works immediately and the
old one stops working. To rotate without downtime, deploy the new key
right after creating it. Events sent with the old key in between get
`401`.
**Revoke** removes the key, and server-side events are rejected until you
create a new one.
Both are also [API operations](/docs/api) (`sites.manage` permission), so
you can rotate keys from your infrastructure tooling.
## Keep it secret
- Never put an ingest key in browser code, mobile apps or public
repositories. For browsers, the public site id and `/api/event` are the
right tool.
- Store it in an environment variable, e.g. `PRIVATUS_INGEST_KEY`.
- The WordPress plugin can read it from `wp-config.php`
(`define( 'PRIVATUS_INGEST_KEY', 'pik_…' );`).
## Ingest keys vs API tokens
| | Ingest key `pik_…` | API token `pat_…` |
|---|---|---|
| Scope | One site | A member's workspace access, narrowed by permissions and sites |
| Can | Send events | Read stats, manage settings, everything in the [API](/docs/api) |
| Used at | `POST /api/events` | Every `.json` endpoint and `/mcp` |
An API token can't send events and an ingest key can't read data.
---
Source: https://privatusanalytics.com/docs/server-side/sessions
# Server-side sessions and session hints
> How server-side events are grouped into visits with the daily salted visit key and how session_hint splits or joins visits.
Server-side events join visits exactly like browser hits.
## The visit key
The visit key is an HMAC of the **daily salt**, the site, the visitor's
IP and User-Agent (and your `session_hint`, if you send one). The key lives
only in memory (Redis, no persistence) for 30 minutes of inactivity and
never past midnight UTC. The salt is random, rotates daily and is never
stored, so the key can't be recomputed later.
- Forward the **visitor's** IP and User-Agent, not your server's, or every
visitor looks like one person.
- If you send the same IP and User-Agent from the browser tracker and from
the server, server events join the visitor's browser visit: a purchase
confirmed by a webhook is attributed to the campaign that brought the
visitor. For that, the webhook needs the IP and User-Agent from the
original checkout request. Store them with the order only for as long as
the checkout takes, then delete them.
## `session_hint`
An optional opaque string, for example a cart id or a job id:
```json
{ "type": "event", "name": "Checkout step", "url": "https://example.com/checkout", "session_hint": "cart_8f2a…", "user_agent": "…" }
```
It's added to the HMAC input, so:
- events with the same hint (and the same IP/User-Agent) form one visit,
- events with different hints are separate visits, even from the same IP
and User-Agent (for example, many users behind one office proxy, or a
backend without the visitor's IP).
The hint is **never stored**: it's hashed together with the daily salt and
discarded. Still, don't use emails or user ids as hints. A random id per
cart or session is enough.
## Timestamps
A `timestamp` up to 72 hours old places the event at that time. Visits are
still keyed by the day the event happened (UTC), so late events join the
right day's visit only if that visit is still in memory (30 minutes
idle). Older back-fills create their own visits.
---
Source: https://privatusanalytics.com/docs/server-side/bots
# Bot filtering rules
> Bot filtering rules for User-Agents, crawlers and data centers, and how to send server-side events with the visitor's User-Agent and IP so they count.
Every hit, browser or server, goes through the same bot checks before it's
counted. Bots are counted in aggregate (reason, country) with **no IP
stored**, and don't count toward your usage.
## Checks, in order
1. **Empty User-Agent** → `bot_empty_user_agent`.
2. **Known crawlers** (Googlebot, Bingbot, GPTBot and the rest of our UA
list) → `bot_known_bot`.
3. **Automation and HTTP libraries** → `bot_automation`. User-Agents
containing any of: `headless`, `phantomjs`, `puppeteer`, `playwright`,
`selenium`, `webdriver`, `electron`, `slimerjs`, `lighthouse`,
`pagespeed`, `gtmetrix`, `pingdom`, `uptimerobot`, `monitage`,
`prerender`, `python`, `curl`, `wget`, `httpclient`, `okhttp`,
`go-http`, `java/`, `axios`, `node-fetch`, `scrapy`.
4. **Data-center networks** (major cloud and hosting providers, by ASN) →
`bot_datacenter`. VPN and relay networks such as iCloud Private Relay
aren't on the list, so real people behind them count.
5. **Referrer spam** domains → `bot_referrer_spam`.
## What this means for server-side events
- Always send the **visitor's** `user_agent`. If you leave it out, or your
HTTP library's default (`python-requests/2.x`, `node-fetch`, `curl/8`,
`Go-http-client`…) ends up in `user_agent`, the event is dropped.
- Send the **visitor's** `ip`, or none at all. Your server's IP is often a
cloud IP and would be filtered as a data center.
- The request's own headers don't matter: the checks use the `user_agent`
and `ip` fields in the body.
## Visits with no input seen
Some bots run a real browser with an ordinary User-Agent, so the checks
above let them through. They tend to load one page and leave without
touching it. To help you spot them, the tracker reports whether it saw any
real input during a visit: a pointer press or move, a key press, a touch or
a wheel turn. It sends a yes or no only, never what the input was (see
[What the tracker sends](/docs/tracker/payload#interaction-flag)).
Every visit then has one of three values in the `interaction` dimension:
| Value | Meaning |
|---|---|
| `seen` | Input was seen on at least one page of the visit |
| `none` | The tracker could tell, and saw none |
| `unknown` | Nothing can tell: server-side events, the no-JavaScript pixel, a self-hosted copy of an older tracker, and all data from before 1 October 2026 |
These visits are **not dropped**, and they count toward your usage like any
other. A person who opens a page and closes it without touching it also
shows as `none`, so read it as a signal, not as proof.
- **Overview → Technology → Interaction** breaks visits down by the three
values. Click a row to filter the dashboard by it.
- To leave them out of a report or an API call, filter with
`["interaction", "is_not", "none"]`. Save it as a
[segment](/docs/dashboard/filters-and-segments) to reuse it.
- **Site settings → Bots** shows how many visits in the last 30 days had no
input seen, with links to both views.
## Turning filtering off
**Site settings → Bots** lets you switch bot filtering off for a site, for
diagnostics. Everything is then counted, including crawlers. Turn it back
on when you're done.
The bot summary on the same tab shows the last 30 days by reason and
country, and the Overview's footer shows how many hits were filtered. For AI
crawler visibility (which never runs JavaScript), use the
[AI crawler logs](/docs/features/ai-crawlers).
---
Source: https://privatusanalytics.com/docs/server-side/examples
# Server-side ingest code examples
> Server-side ingest examples that send an event with curl, Node.js, Python, PHP, Go, Rails and Next.js, forwarding the visitor's User-Agent and IP.
All examples send one `Signup` event for the visitor who made the current
request. Keep the ingest key in `PRIVATUS_INGEST_KEY`.
## curl
```sh
curl -X POST https://privatusanalytics.com/api/events \
-H "Authorization: Bearer $PRIVATUS_INGEST_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": [{
"type": "event",
"name": "Signup",
"url": "https://example.com/signup",
"props": { "plan": "pro" },
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36",
"ip": "203.0.113.7"
}]
}'
```
The `user_agent` must be a real browser's: curl's own User-Agent is only on
the request, not in the event, so it doesn't matter here.
## Node.js (Express)
```js
app.post('/signup', async (req, res) => {
// … create the account …
const response = await fetch('https://privatusanalytics.com/api/events', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PRIVATUS_INGEST_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
events: [{
type: 'event',
name: 'Signup',
url: `https://example.com${req.originalUrl}`,
referrer: req.get('referer'),
props: { plan: req.body.plan },
user_agent: req.get('user-agent'),
ip: req.ip, // set app.set('trust proxy', …) behind a load balancer
}],
}),
})
const { data } = await response.json() // [{ index, status, reason }]
res.redirect('/welcome')
})
```
## Python (Django or Flask)
```python
import os
import requests
def track_signup(request, plan):
requests.post(
"https://privatusanalytics.com/api/events",
headers={"Authorization": f"Bearer {os.environ['PRIVATUS_INGEST_KEY']}"},
json={"events": [{
"type": "event",
"name": "Signup",
"url": request.build_absolute_uri(), # Flask: request.url
"props": {"plan": plan},
"user_agent": request.headers.get("User-Agent"),
"ip": request.META.get("REMOTE_ADDR"), # Flask: request.remote_addr
}]},
timeout=5,
)
```
## PHP
```php
[[
'type' => 'event',
'name' => 'Signup',
'url' => 'https://example.com' . $_SERVER['REQUEST_URI'],
'props' => ['plan' => 'pro'],
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
]]];
$ch = curl_init('https://privatusanalytics.com/api/events');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('PRIVATUS_INGEST_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
```
## Go
```go
func trackSignup(r *http.Request, plan string) error {
body, _ := json.Marshal(map[string]any{
"events": []map[string]any{{
"type": "event",
"name": "Signup",
"url": "https://example.com" + r.URL.RequestURI(),
"props": map[string]any{"plan": plan},
"user_agent": r.UserAgent(),
"ip": clientIP(r), // your helper: RemoteAddr or X-Forwarded-For from a trusted proxy
}},
})
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost,
"https://privatusanalytics.com/api/events", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("PRIVATUS_INGEST_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
```
## Ruby (Rails)
```ruby
require "net/http"
class PrivatusEvents
URL = URI("https://privatusanalytics.com/api/events")
def self.track(request, name, props = {})
Net::HTTP.post(
URL,
{ events: [{ type: "event", name:, url: request.original_url, props:,
user_agent: request.user_agent, ip: request.remote_ip }] }.to_json,
"Authorization" => "Bearer #{ENV.fetch('PRIVATUS_INGEST_KEY')}",
"Content-Type" => "application/json"
)
end
end
# In a controller:
PrivatusEvents.track(request, "Signup", plan: "pro")
```
Consider sending from a background job so a slow network never delays your
response, but **don't put the IP or User-Agent in the job payload** if your
job store is persistent. Send in the request, or drop `ip`.
## Next.js middleware
The [`@privatus/next`](/docs/install/nextjs#server-side-events) package's
`trackPageview()` and `trackEvent()` read the IP and User-Agent from the
incoming request for you and run on the Edge runtime.
> **Warning:** `@privatus/next` is not published on npm yet. Until it is,
> don't install a package with this name from the public registry,
> because anyone could have published it. Call the ingest API
> directly, as in the examples above, instead.
---
Source: https://privatusanalytics.com/docs/server-side/sdks
# Server-side analytics SDKs
> Server-side analytics SDKs for Node.js, Python, PHP, Go and Ruby that wrap the ingest API and batch events. They are not published yet, so read the warning.
The SDKs wrap the ingest API (and the [management API](/docs/api)). They
split lists into batches of 100, convert timestamps to ISO 8601, and return
the per-event results. All are MIT licensed and never log or store the IP
and User-Agent you pass.
| Language | Install | Package |
|---|---|---|
| Node.js (≥ 18, ESM + CJS, TypeScript) | `npm install @privatus/node` | `@privatus/node` |
| Python (≥ 3.9, sync and async) | `pip install privatus` | `privatus` |
| PHP (≥ 8.1) | `composer require privatus/privatus-php` | `privatus/privatus-php` |
| Go (≥ 1.21, standard library only) | `go get github.com/privatus-analytics/privatus-go` | `privatus` |
| Ruby | `gem install privatus` | `Privatus` |
> **Warning:** These SDKs are not published yet (not on npm, PyPI,
> Packagist, the Go module proxy or RubyGems). Until they are, don't
> install packages with these names from public registries, because anyone
> could have published them. Call the ingest API directly instead, as in
> the [examples](/docs/server-side/examples).
## Node.js
```ts
import { Ingest, event } from '@privatus/node'
const ingest = new Ingest(process.env.PRIVATUS_INGEST_KEY!)
const results = await ingest.track([
event('Purchase', 'https://example.com/checkout', {
user_agent: req.get('user-agent'),
ip: req.ip,
revenue: 49.99,
currency: 'USD',
props: { plan: 'pro' },
}),
])
// [{ index: 0, status: 'accepted', reason: null }]
```
## Python
```python
from privatus import Ingest, event
ingest = Ingest(os.environ["PRIVATUS_INGEST_KEY"])
ingest.track([
event("Purchase", "https://example.com/checkout",
user_agent=request.headers.get("User-Agent"),
revenue=49.99, currency="USD", props={"plan": "pro"}),
])
```
## PHP
```php
use Privatus\Ingest;
$ingest = new Ingest(getenv('PRIVATUS_INGEST_KEY'));
$ingest->track([
Ingest::event('Purchase', 'https://example.com/checkout',
props: ['plan' => 'pro'], userAgent: $_SERVER['HTTP_USER_AGENT'] ?? null,
revenue: 49.99, currency: 'USD'),
]);
```
## Go
```go
ingest := privatus.NewIngest(os.Getenv("PRIVATUS_INGEST_KEY"))
results, err := ingest.Track(r.Context(),
privatus.CustomEvent("Purchase", "https://example.com/checkout", privatus.EventOptions{
UserAgent: r.UserAgent(),
Revenue: 49.99,
Currency: "USD",
Props: map[string]any{"plan": "pro"},
}),
)
```
## Ruby
```ruby
require "privatus"
ingest = Privatus::Ingest.new(ENV.fetch("PRIVATUS_INGEST_KEY"))
ingest.track([
Privatus::Ingest.event("Purchase", "https://example.com/checkout",
user_agent: request.user_agent, revenue: 49.99,
currency: "USD", props: { plan: "pro" })
])
```
## Mobile apps
SDKs for Swift, Kotlin, Flutter and React Native track screens as
pageviews. Their READMEs cover installation. They use the same public
collection endpoint as the browser tracker, never an ingest key.
---
Source: https://privatusanalytics.com/docs/dashboard
# Using the analytics dashboard
> A tour of the Privatus Analytics dashboard: layout, navigation, shareable URLs that hold every view, and links to each page's guide.
## Layout
- **Left sidebar:** the workspace switcher at the top, then two sections.
**Site** (shown while you view a site) has Overview, Live, Pages,
Sources, Campaigns, Goals, Funnels, Events, Segments, Notes, Journeys,
Performance, Uptime, Search, AI crawlers, Insights, Ask AI, Alerts,
Imports and Settings. **Workspace** has All sites, Dashboards, Email
reports, Alerts, Notes, Exports, Uptime, Status pages, Members, API
tokens, Webhooks, Billing, Audit log and Settings. Items your role
can't open are hidden. At the bottom are the API and MCP docs, your
account settings, the theme switch and Log out.
- **Docs link:** every page links to its guide in these docs, above the
page content. It opens in a new tab.
- **On phones** the sidebar opens from the menu button in a small top
bar, which also shows the site name and the Docs link.
Every page works down to 360 px wide, in light and dark themes.
## The URL is the state
The date range, comparison, filters and segment live in the URL. Copy it
to share exactly what you see with a teammate, bookmark a view, or add
`.json` to get the same data from the [API](/docs/api):
```text
/sites/pa_7Q2K9XH3AB/overview?period=30d&filters=[["channel","is","organic_search"]]
```
## Pages
- [All sites](/docs/dashboard/all-sites)
- [Overview](/docs/dashboard/overview)
- [Date ranges and comparisons](/docs/dashboard/date-ranges)
- [Filters and segments](/docs/dashboard/filters-and-segments)
- [Drill-down tables](/docs/dashboard/drill-down)
- [Page detail](/docs/dashboard/page-detail)
- [Live and wallboard](/docs/dashboard/live)
- [Events explorer](/docs/dashboard/events)
- [Notes](/docs/dashboard/notes)
- [Insights](/docs/dashboard/insights)
- [Ask (AI assistant)](/docs/dashboard/ask)
- [Custom dashboards](/docs/dashboard/custom-dashboards)
- [Keyboard shortcuts](/docs/dashboard/keyboard-shortcuts)
- [Site settings](/docs/dashboard/site-settings),
[workspace settings](/docs/dashboard/workspace-settings),
[account settings](/docs/dashboard/account-settings)
Goals, funnels, journeys, campaigns, performance, uptime and search have
their own section: [Goals, funnels & more](/docs/features).
---
Source: https://privatusanalytics.com/docs/dashboard/all-sites
# All sites dashboard
> The all sites dashboard lists every website in your workspace with live visitors, 30-day visitor trends and sparklines. Learn to sort, search and label sites.
The first page after signing in lists every site in the workspace that
you can access.
## Site tiles
Each site shows:
- its name and domain, and a star to make it a **favorite** (favorites
come first),
- live visitors (last 5 minutes),
- visitors in the last 30 days, with the change against the 30 days
before,
- a sparkline,
- a badge when no data has arrived recently, or while the site is waiting
for its first hit.
A **totals strip** adds up sites, visitors, the change and live visitors
across the page.
## Controls
- **Sort** by your order, name or visitors.
- **Search** by name or domain, and filter by **labels** (set labels in
Site settings → General).
- **Add site**.
---
Source: https://privatusanalytics.com/docs/dashboard/overview
# Analytics dashboard overview
> The main site dashboard explained: headline metrics like visitors and bounce rate, the main chart, comparisons, filters and breakdown panels by source and page.
## Toolbar
- **Date range** with presets (each with a [keyboard shortcut](/docs/dashboard/keyboard-shortcuts)
where shown) and a custom range. See [Date ranges](/docs/dashboard/date-ranges).
- **Compare** with the previous period, or the same period last year.
Custom comparison ranges are available through the API
(`compare=custom` with `compare_from` and `compare_to`).
- **Segment** picker and the **filter builder** (see
[Filters and segments](/docs/dashboard/filters-and-segments)).
- **Show imported data**, when the site has [imports](/docs/reports/imports).
It adds imported history for dates before your first tracked event and
is not applied while filters or a segment are active.
- **JSON**: the same view from the API.
## Headline metrics
Visitors, Visits, Pageviews, Views per visit, Bounce rate, Visit duration,
plus Revenue (when the site has revenue). Each card shows the value and the
change against the comparison period, with the previous value underneath.
Click a card to plot it on the main chart. Every metric is defined in
[Metrics & definitions](/docs/metrics).
A **Live** pill shows the current visitor count and links to
[Live](/docs/dashboard/live).
## Main chart
- **Interval:** hour, day, week or month. The API also accepts minute
(today only), quarter, year and auto.
- The comparison period is drawn as a dashed series.
- The current, incomplete period is drawn dotted.
- [Notes](/docs/dashboard/notes) in the range are listed under the chart.
## Panels
| Panel | Tabs |
|---|---|
| Sources | Channels · Referrers · URLs |
| Pages | Top · Entry · Exit · Hostnames |
| Locations | Countries · Languages |
| Technology | Devices · Browsers · OS · Screens · Interaction |
| Campaigns | Campaign · Source · Medium · Content · Term |
| Conversions | Goals · Events |
In every panel you can:
- **click a row** to filter the whole dashboard by it,
- open **View all** for the full [drill-down table](/docs/dashboard/drill-down).
More dimensions (for example `ref`, browser and OS versions) are available
in the drill-down table and through the `breakdown` API operation.
Location is country only. Regions and cities aren't collected.
---
Source: https://privatusanalytics.com/docs/dashboard/date-ranges
# Date ranges and comparisons
> Pick date range presets or custom dates, compare with the previous period or the same period last year, and choose chart intervals, all in the site's timezone.
## Presets
| Preset | API value |
|---|---|
| Today | `today` |
| Yesterday | `yesterday` |
| Last 24 hours | `24h` |
| Last 7 / 14 / 28 / 30 / 90 days (including today) | `7d`, `14d`, `28d`, `30d`, `90d` |
| Month to date | `mtd` |
| Last month | `last_month` |
| Quarter to date | `qtd` |
| Last quarter | `last_quarter` |
| Year to date | `ytd` |
| Last 12 months | `12mo` |
| Last calendar year | `last_year` |
| All time (from the first event) | `all` |
| Custom | `custom` with `from` and `to` (inclusive, `YYYY-MM-DD`) |
The default is **Last 30 days**. ◀ and ▶ (or `[` and `]`) step back and
forward by one period.
## Timezone
Ranges are calendar days in the **site's timezone** (Site settings →
General). Data is stored in UTC, so changing the timezone re-buckets
history instantly. "Last 24 hours" is a rolling window. See
[Timezones](/docs/troubleshooting/timezones).
## Comparisons
| Option | API value |
|---|---|
| Off | `none` |
| Previous period (the same length, immediately before) | `previous_period` |
| Same period last year | `previous_year` |
| Custom | `custom` with `compare_from` and `compare_to` |
**Match day of week** (`match_weekday=true`) shifts the comparison by whole
weeks (previous period) or 52 weeks (previous year), so Mondays are
compared with Mondays.
## Intervals
`auto` picks hours for one day or less, days up to 92 days, weeks up to a
year and months beyond. You can choose `minute` (ranges of one day or
less), `hour` (up to 14 days), `day`, `week`, `month`, `quarter` or
`year`.
---
Source: https://privatusanalytics.com/docs/dashboard/filters-and-segments
# Analytics filters and segments
> Filter every dashboard report by source, page, country, event or property, combine conditions with AND and OR groups, and save them as reusable segments.
## Filters
Click any row in a panel to add an `is` filter (Alt/⌥-click for `is not`),
or use **+ Filter** (`F`) to build one: choose a dimension, an operator and
one or more values. Active filters show as chips. Click a chip to change
it, **Clear** (`Esc`) to remove them all.
Filters apply to every metric, chart and panel on the page, and are kept
in the URL.
### Operators
| Operator | Meaning |
|---|---|
| `is` / `is_not` | Equals / doesn't equal |
| `any_of` / `none_of` | Equals one of a list / none of a list |
| `contains` / `not_contains` | Substring match, case-insensitive |
| `starts_with` | Prefix match |
| `regex` / `not_regex` | Regular expression (up to 200 characters) |
| `gt`, `lt`, `between` | Numbers, for numeric properties |
### AND and OR
Separate filters are combined with **AND**. Put conditions in an OR group
to match any of them: "channel is Email **or** utm_medium is newsletter".
Up to 20 conditions in total.
### What a filter selects
- **Visit dimensions** (source, channel, UTM, country, device, browser,
entry page…) select visits.
- **Page** selects visits that viewed the page, and pageview counts for
that page.
- **Event**, **goal** and **property** filters select visits in which that
event or goal happened.
The full list of dimensions and the JSON syntax used in URLs and the API
are in [Filters (API)](/docs/api/filters).
## Segments
A segment is a saved set of filters with a name, like "Paid traffic from
the US" or "Visits that saw pricing". Choose one with the **Segment**
picker (`S`). It combines with any filters you add on top.
Create one with **+ New segment**, or save the current filters. Segments
belong to the site and are shared with everyone who can see it. Use them in
the API with `segment_id=seg_…`.
---
Source: https://privatusanalytics.com/docs/dashboard/drill-down
# Drill-down report tables
> Open the full drill-down table for any dimension, switch dimensions, filter by clicking a row and export up to 1,000 rows as CSV or JSON, also through the API.
**View all** on any panel (or Pages and Sources in the navigation) opens the
full table for that dimension.
- **Paginated**, with previous and next links.
- **Switch dimension** from the list at the top.
- **Click a row** to filter by it. Page rows link to the
[page detail](/docs/dashboard/page-detail) view.
- **Export** the table as CSV (up to 1,000 rows), or open it as JSON.
The table respects the current date range, filters and segment, shown at
the top. Through the API you can also sort by visitors, visits, pageviews
or revenue.
## API
Each table is the `breakdown` operation:
```sh
curl "https://privatusanalytics.com/sites/pa_7Q2K9XH3AB/breakdown/page.json?period=30d&limit=100&page=2" \
-H "Authorization: Bearer $PRIVATUS_TOKEN"
```
Add `.csv` instead of `.json` to download the rows as CSV. See
[Stats endpoints](/docs/api/stats).
---
Source: https://privatusanalytics.com/docs/dashboard/page-detail
# Page analytics detail view
> Everything about one page in one view: visitors, entry and exit rates, time on page, scroll depth, previous and next pages, sources and Web Vitals by device.
Opens when you filter on a single page, or from a row's menu in the Pages
panel.
- **Header:** the path, a link to the live URL, the last seen page title
and the filters in use.
- **Metrics:** visitors, pageviews, entry rate, exit rate, bounce rate as an
entry page, median time on page, median scroll depth, and conversions
after viewing the page.
- **Journeys:** the top **previous pages** (including "entrance") and
**next pages** (including "exit") with their shares. Click one to step
along the path.
- **Sources:** channels and referrers of visits that entered on this page.
- **Web Vitals:** p75 values and the distribution by device (with the
`vitals` module).
- **Events** fired on this page.
- **Notes** that mention this path.
---
Source: https://privatusanalytics.com/docs/dashboard/live
# Real-time visitors and wallboard
> See real-time visitors, a live activity stream and top pages from the last 30 minutes, and put a full-screen wallboard on an office TV with a secret link.
## Live
- **Live visitors:** distinct visits seen in the last 5 minutes, with a
30-minute sparkline at one-minute resolution.
- **Activity stream:** pageviews and events as they arrive, with the
relative time, page, event name, channel, country flag, device and
browser. Pause and resume it, or show only events or only pageviews.
- **Map:** countries with hits in the last 30 minutes.
- **Right now:** top pages, referrers, events and countries in the last 30
minutes.
What you'll never see here, by design: IP addresses, cities, names, or any
trail of what one visitor did.
Hits reach the Live page in about two seconds.
## Wallboard
`/sites//live/wallboard` shows big numbers full screen, for an
office TV:
- visitors right now, today's visitors, pageviews and bounce rate, and the
top pages, countries and referrers right now,
- rotate between several sites every N seconds,
- dark theme with slow movement so screens don't burn in.
### Wallboard links
A TV shouldn't be signed in to your account. Create a **wallboard link**
(Live → Wallboard → Links): a secret URL that shows the wallboard without
signing in. Links are read-only, limited to the sites you choose, and can
be revoked at any time.
---
Source: https://privatusanalytics.com/docs/dashboard/events
# Custom events explorer
> Explore every custom event with completions, unique visitors and conversion rate, pivot event properties, aggregate numeric values, and rename or merge events.
**Events** lists every custom event name for the range with:
- completions (total times it fired),
- unique visitors and visits with the event,
- conversion rate (visits with the event ÷ all visits),
- first and last seen.
Search the list by name. The same list is available as JSON from the
`events_list` API operation.
## Event detail
Click an event for:
- a chart over time,
- a **property pivot**: choose up to three properties as rows or columns,
- for number properties, the **sum, average, median and p90**,
- the pages where it fired and the sources of visits that fired it.
## Managing events
From Events, or **Site settings → Events & properties**:
| Action | Effect |
|---|---|
| Rename | Shows the event under a new name, for all history (applied at query time) |
| Merge | Combine several names into one (`Sign up` + `signup` → `Signup`) |
| Hide | Hide an event from lists without deleting data |
| Delete | Permanently delete the event **including its history**. Confirm by typing the name |
| Currency override | Treat an event's revenue as a given currency |
## Property settings
Click **Property settings** on the Events page, or **Site settings →
Events & properties → Manage properties**. The page lists the
[property](/docs/events/properties) keys of the site's custom events:
- every key seen in the last 30 days (up to 200), most used first, with
the number of events that carried it,
- keys you saved a setting for earlier, even when no event carried them
in the last 30 days.
Each row has three settings and its own **Save** button:
| Setting | Effect |
|---|---|
| **Label** | A display name shown in place of the key in an event's property list and pivot (up to 64 characters). Clear it to show the key again |
| **Hidden** | Leaves the key out of the property list on the [event detail](/docs/dashboard/events#event-detail) page |
| **No personal data** | Confirms the key never holds personal data. Only keys marked this way are included in raw event exports and warehouse syncs |
Labels and hiding are applied when a report is built. Stored data is not
changed, and the key itself stays the same, so filters and the API still
use it as `prop:`.
> **Note:** Row-level data leaves the product without any property you
> haven't marked. To get `plan` into a raw event export (Pro and up) or
> a warehouse sync (Business), tick **No personal data** for `plan`
> first. See [Exports](/docs/reports/exports).
Seeing the page needs `analytics.read`. Changing a setting needs
`sites.manage` (Owner, Admin, Editor). Other members see the labels and
flags without the form.
Free: 10 distinct property keys per site. Paid plans: unlimited. On
Free, keys already in use keep working, and an event that brings an 11th
key is stored without that property. The list of keys in use is rebuilt
every day from the most used keys of the last 30 days, so a key you stop
sending frees its place.
## API
Events are under `/sites//events`, with MCP tools named
`events_*` (`events_list`, `events_get`, `events_update`, `events_merge`,
`events_delete`).
Property settings are `GET /sites//events/properties` (MCP:
`event_properties_list`, with an optional `event` to list only the keys
seen on one event name) and `PATCH /sites//events/properties`
(MCP: `event_properties_update`, with `key`, `display_name`, `hidden` and
`export_safe`).
The [API reference](/docs/api) lists every input.
---
Source: https://privatusanalytics.com/docs/dashboard/notes
# Chart notes and annotations
> Annotate analytics charts with notes for launches, campaigns and incidents, and get automatic notes for deploys, downtime, imports and Web Vitals regressions.
Notes appear as pins on every chart. Each note has:
- a date or a date range,
- a title and optional Markdown text,
- an optional link,
- a color,
- a scope: this site, or all sites in the workspace.
Add one from the chart (click a date, or press `N`), from **Site settings →
Notes**, or through the API (for example from your deploy pipeline).
## Automatic notes
- **Deploys**: post a note with `source: "deploy"` from your CI or deploy
script. **Site settings → Notes** shows a ready-to-copy `curl` command
(it needs an API token with `content.write`).
- **Incidents**: when an [uptime check](/docs/features/uptime) linked to the
site goes down.
- **Imports**: when historical data is added, spanning the imported dates.
- **Web Vitals regressions**: when a Core Web Vital gets more than 20%
worse week over week.
---
Source: https://privatusanalytics.com/docs/dashboard/insights
# Automatic traffic insights
> Insights automatically detects notable changes in your traffic, such as a source sending more visitors or a page losing views, against your site's own history.
**Insights** looks for notable changes this week compared with the usual
pattern: a source that suddenly sends more visitors, a page whose traffic
dropped, a goal whose conversion rate moved, a country or campaign that
appeared. Each insight links to the dashboard view that shows it.
Insights compare against a baseline built from your own history, so they
get better as a site collects more data. Use
[alerts](/docs/reports/alerts) with the *anomaly* rule to be notified about
changes like these as they happen.
---
Source: https://privatusanalytics.com/docs/dashboard/ask
# Ask the AI analytics assistant
> Ask questions about your website analytics in plain language and get numbers, charts and the exact queries behind them, with zero data retention by the model.
**Ask** is a side panel on every page. Type a question such as "Which
campaigns brought the most signups last month?" and get the numbers, a
chart and a short explanation.
## How it answers
1. Your question is turned into a plan and then into queries against **our
own query API**: the same aggregate, timeseries and breakdown
operations as the [API](/docs/api/stats). It never runs raw SQL.
2. The answer includes **How I got this**, with the exact queries. Open
them as a dashboard view to check or continue.
Suggested prompts relate to the page you're on. It can also explain a
metric, draft a goal or funnel for you to confirm, write a weekly summary
and check an installation.
## Privacy
- Runs on a US LLM endpoint with **zero data retention**. Your data is
never used for training.
- Only aggregated results of your queries are sent to the model, never raw
events.
- Your prompts and answers are kept for 30 days as your history, and you
can delete them.
## Limits
Questions per month depend on your plan: 20 on Free, 300 on Pro and 2,000
on Business. See [Plans](/docs/billing/plans).
---
Source: https://privatusanalytics.com/docs/dashboard/custom-dashboards
# Custom dashboards across your sites
> Build custom dashboards from metric, chart, breakdown, goal, funnel, live visitor, uptime and Web Vitals cards, mixing any sites you can access in a workspace.
A custom dashboard is a named page of cards. Each card is a small report
for one site, and one dashboard can mix cards from any sites you can
access in the workspace, for example one dashboard per team or client.
Open **Dashboards** in the Workspace section of the sidebar. The list is
sorted by name and shows how many cards each dashboard has. Dashboards
belong to the workspace, so every member who can read analytics sees the
same list.
## Creating and editing
Click **New dashboard** (or press `N` on the list). On a dashboard, click
**Edit** (or press `E`) to change it, or **Delete** to remove it after a
confirmation.
1. Give the dashboard a **name** (required, up to 100 characters).
2. Click **Add card** and choose the card's type and site. The title is
optional (up to 80 characters). Without one, the card is headed with
its type.
3. Set the options for that type. The form shows only the options the
chosen type uses.
4. Use the arrow buttons to move a card up or down, and the remove button
to drop it. Cards appear on the dashboard in the order of the list.
5. Click **Save dashboard**.
A dashboard holds up to 40 cards. At 40, **Add card** is turned off.
> **Tip:** `N` and `E` are part of the
> [keyboard shortcuts](/docs/dashboard/keyboard-shortcuts), which you can
> turn off in your [account settings](/docs/dashboard/account-settings).
## Card types
| Type | What it shows | Options |
|---|---|---|
| **Metric** | One metric for the period, the change against the previous period and the previous value | Period, metric |
| **Chart** | One metric over time. On wide screens it takes two columns | Period, metric |
| **Breakdown** | The top 5 values of one dimension, with visitors (pageviews for pages, completions for events) | Period, breakdown |
| **Goals** | Up to 5 of the site's [goals](/docs/features/goals), most completions first, with their conversions | Period |
| **Live visitors** | Visitors in the last 5 minutes | None |
| **Funnel** | A [funnel](/docs/features/funnels)'s overall conversion rate, and the count and conversion rate at each step | Period, funnel id (optional) |
| **Uptime** | Up to 5 of the site's [uptime checks](/docs/features/uptime), with their status and uptime over the last 7 days | None |
| **Web Vitals** | The 75th percentile and rating of LCP, INP, CLS, FCP and TTFB. See [Performance](/docs/features/performance) | Period |
The **breakdown** option offers Page, Channel, Referrer, UTM source, UTM
campaign, Country, Device, Browser, Operating system, Entry page and
Event. It starts on Page.
The **funnel id** is the funnel's id, which starts with `fun_`. Leave it
empty and the card shows the site's oldest funnel.
## Periods and metrics
Each card has its own period, counted in its site's timezone. Choose any
[date range preset](/docs/dashboard/date-ranges#presets), from Today to
All time. The default is Last 30 days. Custom date ranges, filters and
segments are not part of a card.
Metric and Chart cards show one metric: Visitors (the default), Visits,
Pageviews, Views / visit, Bounce rate, Visit duration or Revenue (in the
site's currency).
The numbers come from the same reports as the site's own pages, so a card
and the site's [Overview](/docs/dashboard/overview) agree for the same
period. See [Metrics](/docs/metrics) for how each one is counted.
## Permissions
| Action | Permission | Built-in roles |
|---|---|---|
| View the list, a dashboard and its cards | `analytics.read` | Owner, Admin, Editor, Analyst, Viewer |
| Create, edit and delete dashboards | `content.write` | Owner, Admin, Editor, Analyst |
See [Roles and permissions](/docs/teams/roles) for the full matrix.
Site access applies card by card:
- The site list in the form holds only the sites you can access. Saving a
new card for any other site is refused.
- A card for a site you can't access shows "You don't have access to this
card's site." in place of its data. The rest of the dashboard works as
usual.
- When you edit a dashboard, cards that a member with wider access added
are kept, even for sites you can't see.
## How cards load
The dashboard opens at once and each card then loads by itself as it
comes into view, so a slow card never holds up the others. Until its data
arrives, a card shows its title and "Loading…".
- The site name in a card's header opens that site's overview for the
card's period.
- A card with nothing to report says "Nothing to show for this card yet.",
for example a Funnel card on a site without funnels or an Uptime card
on a site without checks.
- Cards load once per page view. Reload the page for fresh numbers.
Cards sit in one column on phones, two on tablets and three on wide
screens.
## API
Dashboards are a REST resource under
`/workspaces//dashboards` and MCP tools named `dashboards_*`
(`dashboards_list`, `dashboards_get`, `dashboards_create`,
`dashboards_update`, `dashboards_delete`). Dashboard ids start with
`dash_`. The [API reference](/docs/api) lists every input.
A card is an object with a `type`, a `site_id`, an optional `title` and
`params`:
```json
{
"name": "Client: Acme",
"cards": [
{ "type": "metric", "site_id": "pa_7Q2K9XH3AB",
"params": { "period": "7d", "metric": "pageviews" } },
{ "type": "breakdown", "site_id": "pa_7Q2K9XH3AB", "title": "Countries",
"params": { "dimension": "country", "limit": 10 } }
]
}
```
| `type` | `params` |
|---|---|
| `metric` | `period`, `metric` |
| `chart` | `period`, `metric`, `interval` |
| `breakdown` | `period`, `dimension`, `limit` |
| `goal` | `period`, `limit` |
| `funnel` | `period`, `funnel_id` |
| `live` | None |
| `uptime` | `limit` |
| `vitals` | `period` |
- `limit` is the number of rows, from 1 to 10 (default 5).
- `dimension` takes any [dimension](/docs/api/filters#dimensions),
including `prop:`.
- `interval` takes the chart [intervals](/docs/dashboard/date-ranges#intervals)
(default `auto`).
- Params a type does not use are dropped. A card with an unknown type,
period, metric or dimension is refused, and the error names the card's
position.
- Sending `cards` in an update replaces the whole list.
> **Note:** The form has no fields for `limit` and `interval`, so saving
> a dashboard from the form resets them to their defaults.
`GET /workspaces//dashboards//cards/`
(MCP: `dashboards_card`) returns the data for one card, counting from 0.
Its `status` is `ok`, `no_access` or `not_available`.
---
Source: https://privatusanalytics.com/docs/dashboard/keyboard-shortcuts
# Dashboard keyboard shortcuts
> Every keyboard shortcut in the Privatus Analytics dashboard: date range presets, comparison, filters, segments, the Live page, and how to turn hotkeys off.
Press `?` in the app to open this list. Shortcuts can be turned off in
**Account settings → Preferences**.
On the dashboard:
| Key | Action |
|---|---|
| `T` | Today |
| `Y` | Yesterday |
| `W` | Last 7 days |
| `M` | Last 30 days |
| `Q` | Quarter to date |
| `A` | All time |
| `C` | Toggle comparison |
| `F` | Add a filter |
| `S` | Segment picker (when the site has segments) |
| `L` | Live |
| `Esc` | Clear filters |
| `?` | This list |
On list pages (goals, funnels, notes, alerts and others), `N` creates a new
item. On a custom dashboard, `E` edits it.
In these docs, `/` focuses the search box.
---
Source: https://privatusanalytics.com/docs/dashboard/site-settings
# Site settings reference
> A guide to every site settings tab: domains, tracking snippet, privacy, traffic and bot rules, goals, channels, alerts, sharing, integrations and deletion.
**Settings** at the end of a site's section in the sidebar opens the
site's settings, split into 16 tabs. Opening them needs the
`sites.manage` permission (Owner, Admin and Editor). Most settings are
also [API operations](/docs/api) with the same permission.
| Tab | What's there |
|---|---|
| [**General**](/docs/dashboard/site-settings#general) | Name, primary domain, additional domains (combined or separate), timezone, currency, week start, labels, site id |
| [**Tracking**](/docs/dashboard/site-settings#tracking) | Snippet generator, platform guides, **Verify installation**, [server ingest key](/docs/server-side/ingest-keys), Web Vitals sample rate |
| [**Privacy**](/docs/dashboard/site-settings#privacy) | [DNT/GPC handling](/docs/privacy/dnt-gpc), query parameter allowlist, path masking rules, [PII scrubber](/docs/privacy/pii-scrubbing) and custom rules, redaction log (counts only), data retention |
| **Traffic rules** | Block or allow lists for hostnames, paths, referrers, countries, IP ranges, user agents and events, with a live test box. See [Traffic rules](/docs/privacy/traffic-rules) |
| **Bots** | Bot filtering on/off, 30-day bot summary by reason and country, and how many visits had [no input seen](/docs/server-side/bots#visits-with-no-input-seen). Block a spam referrer with a [traffic rule](/docs/privacy/traffic-rules). See [Bot rules](/docs/server-side/bots) |
| **Goals** / **Funnels** | Your [goals](/docs/features/goals) and [funnels](/docs/features/funnels), with links to create and manage them |
| **Events & properties** | Links to manage events (rename, merge, hide, delete) and property settings. See [Events explorer](/docs/dashboard/events#managing-events) |
| **Channels** | Custom [channel rules](/docs/metrics/channels#custom-channel-rules) with a preview |
| **Notes** | Recent [notes](/docs/dashboard/notes) and a ready-to-copy deploy note command |
| **Alerts** | This site's [alert rules](/docs/reports/alerts) and where they're sent |
| **Sharing** | [Public and password links, embeds, badges](/docs/reports/sharing), and a link to wallboard links |
| [**Integrations**](/docs/dashboard/site-settings#integrations) | [Search Console and Bing](/docs/features/search-console) connection status, plus links to webhooks, notification channels (Slack, Teams and more), API tokens, warehouse export, [AI crawler logs](/docs/features/ai-crawlers) and uptime |
| **Imports** | Recent [GA4 and CSV imports](/docs/reports/imports) and a link to start one |
| [**Access**](/docs/dashboard/site-settings#access) | Which members can see this site |
| [**Danger zone**](/docs/dashboard/site-settings#danger-zone) | Transfer the site to another workspace, reset data for a date range, delete the site |
## General
The site's identity and the defaults its reports use. One **Save** button
stores the whole tab.
| Field | What it does |
|---|---|
| **Name** | The name shown in the sidebar, on [All sites](/docs/dashboard/all-sites) and in reports. Up to 100 characters |
| **Primary domain** | The site's main hostname, for example `example.com`. A pasted URL is reduced to its hostname. A domain can belong to only one active site |
| **Timezone** | The timezone that days and date ranges are counted in. Changing it regroups all history at once. See [Timezones](/docs/troubleshooting/timezones) |
| **Currency** | A three-letter code such as `USD`. [Revenue](/docs/events/revenue#multi-currency) sent in another currency is converted to it. A new site starts with the workspace's currency |
| **Week starts on** | Monday or Sunday |
| **Additional domains** | Other hostnames that send data to this site, one per line or separated by commas |
| **Multiple domains** | **Combined** (the default): one visit can span all the domains, and reports show them together. **Separate**: each domain starts its own visits, and you compare them with a hostname filter |
| **Labels** | Comma separated tags. Filter sites by label on the All sites page |
| **Site id** | The `pa_…` id used in the snippet's `data-site` and in API paths, with a **Copy** button. It never changes |
Hits are accepted only from the primary domain, the additional domains
and any of their subdomains (`www.`, `shop.` and so on). Hits from other
hostnames are dropped. [Cross-domain and subdomains](/docs/troubleshooting/cross-domain)
explains when to combine domains and when to create separate sites.
Through the API, these are fields of `PATCH /sites/` (MCP:
`sites_update`).
## Tracking
Everything needed to get data in: the snippet, install help, a check that
it works, the key for server-side events and Web Vitals sampling.
### Snippet generator
Choose options and the snippet below them updates. Paste it into the
`` of every page. Each option becomes one attribute of the script
tag:
| Option | Attribute | Effect |
|---|---|---|
| **Modules** | `data-modules` | `engage` (time on page and scroll depth, on by default), `auto` (outbound links, file downloads, form submits and 404s), `vitals` (Core Web Vitals, sampled) and `clicks` (aggregate element click counts). See [Modules](/docs/tracker/modules) |
| **Single-page apps** | `data-spa` | Automatic (history and hash, the default), History API only, Hash routing (keeps the `#fragment`) or Off. See [Single-page apps](/docs/tracker/spa) |
| **Manual pageviews only** | `data-manual` | The script sends no pageview by itself. See [Manual mode](/docs/tracker/manual-mode) |
| **Only track on hostnames** | `data-domains` | Runs the tracker only on the hostnames you list |
| **Exclude paths** | `data-exclude` | Skips matching paths, for example `/admin/*, /preview/*` |
| **Keep query parameters** | `data-params` | Sends the listed query parameters with page URLs, for example `page, ref`. They are stored only when the [Privacy](/docs/dashboard/site-settings#privacy) tab allows them too |
| **Mask paths** | `data-mask` | Rewrites paths in the browser, for example `/u/*->/u/:id` |
The last four are covered in
[Exclusions and masking](/docs/tracker/exclusions-and-masking), and
[Script attributes](/docs/tracker/attributes) lists every attribute.
> **Note:** The generator only builds a snippet. Its choices are not
> saved with the site, so the form is back on the defaults the next time
> you open the tab. What counts is the snippet on your pages.
### Platform guides
Short steps and ready code (already holding your site id) for HTML,
WordPress, Shopify, Webflow, Next.js, Nuxt, Google Tag Manager, Ghost,
Squarespace, Wix and Framer. The [install guides](/docs/install) cover
these and more platforms in detail.
### Verify installation
Enter a page URL (the homepage is filled in) and click **Verify**. We
fetch the page and report whether the script is there, whether it carries
this site's id, and whether a Content-Security-Policy or Referrer-Policy
header gets in the way. The URL must be on the site's primary or
additional domains. The section shows when the site was last verified.
See [Verify your installation](/docs/getting-started/verify-installation).
### Server ingest key
The key for sending events from your servers to `POST /api/events`.
- **Create key** shows the key once, with a ready `curl` example. We store
only a hash of it, and afterward the tab shows just the key's prefix.
- **Rotate key** replaces it. The old key stops working immediately.
- **Revoke** removes it.
See [Ingest keys](/docs/server-side/ingest-keys).
### Web Vitals sampling
**Sample rate (%)** is the share of page loads that report Core Web
Vitals when the `vitals` module is on. Free: up to 10%. Paid plans: any
rate up to 100%. If the workspace moves to a plan with a lower maximum,
that maximum applies without editing the site. Web Vitals don't count
toward your event allowance. See
[Performance](/docs/features/performance).
Through the API: `GET /sites//tracking/snippet`
(`tracking_snippet`), `POST /sites//tracking/verify`
(`tracking_verify`), `POST` and `DELETE /sites//tracking/ingest_key`
(`tracking_ingest_key_rotate`, `tracking_ingest_key_revoke`), and the
`vitals_sample_rate` field of `sites_update`.
## Privacy
What is dropped, shortened or redacted before anything is stored, and how
long data is kept. The first four sections are one form with a single
**Save** button. Signals, URL rules and the scrubber apply to hits
received after you save. Stored data is not rewritten.
### Visitor signals
| Setting | Default | Effect |
|---|---|---|
| **Honor Global Privacy Control (GPC)** | On | Hits from browsers that send GPC are dropped |
| **Honor Do Not Track (DNT)** | Off | Hits from browsers that send DNT are dropped |
See [DNT and GPC](/docs/privacy/dnt-gpc).
### URLs
- **Allowed query parameters**: comma separated, for example
`page, lang`. Query strings are removed before storage, except UTM
tags, `ref` and the parameters you allow here. The tracker also removes
other parameters in the browser, so list the same names in the
snippet's
[`data-params`](/docs/tracker/exclusions-and-masking#keep-query-parameters-data-params).
- **Path masks**: rows of a pattern and a replacement. A `*` matches
exactly one path segment, so the pattern `/u/*/settings` with the
replacement `/u/:id/settings` stores every user's settings page as one
path. The first mask that matches a path is used. **Add mask** adds a
row.
Path masks are applied on our servers. The snippet's `data-mask` does
the same in the browser, before the path is sent.
### PII scrubber
**Scrub personal data** (on by default) redacts personal data from
paths, referrers and event properties before storage. The tab lists the
built-in rules, which [PII scrubbing](/docs/privacy/pii-scrubbing)
describes one by one.
**Custom redaction rules** add your own: a rule name and a regular
expression per row, for example `order_id` and `ORD-\d{6}`. Matches are
replaced with `[redacted]`. A pattern that is not a valid regular
expression is refused when you save.
### Data retention
Data older than the chosen age is deleted automatically, once a day.
| Choice | Meaning |
|---|---|
| **Plan default** | Keep data as long as the plan allows |
| 3, 6, 12, 13, 24, 36 or 60 months | Delete this site's data sooner than the plan would |
Only ages within the plan's limit are offered. Free: up to 6 months. Paid
plans keep data without a limit, so every choice is available. The age
in force is always the shorter of this setting and the plan's limit.
### Redaction log
How often each scrubber rule redacted something in the last 30 days. The
redacted values themselves are never stored.
Through the API, the privacy settings are the `honor_gpc`, `honor_dnt`,
`allowed_params`, `path_masks`, `pii_scrubber`, `redaction_rules` and
`retention_months` fields of `sites_update`.
## Integrations
This tab has nothing to save. It shows what the site is connected to and
links to the tools that work with it.
**Search Console and Bing** lists Google Search Console and Bing
Webmaster Tools, each with the connected property or "Not connected".
**Connect** (or **Open**) goes to the site's Search page for that
provider. See [Search Console and Bing](/docs/features/search-console).
**More integrations** links to:
| Link | What it is | Guide |
|---|---|---|
| **Webhooks** | Send workspace events to your own endpoint | [Webhooks](/docs/api/webhooks) |
| **Notification channels** | Email, Slack, Teams, Discord and more, used by alerts | [Alerts](/docs/reports/alerts) |
| **API tokens and MCP** | For the JSON API and AI agents | [Authentication](/docs/api/authentication), [MCP](/docs/mcp) |
| **Warehouse export** (Business) | Daily export to S3, BigQuery or Snowflake | [Exports](/docs/reports/exports) |
| **AI crawlers** | Server log ingest for AI crawler visits | [AI crawlers](/docs/features/ai-crawlers) |
| **Uptime checks** | Monitor this site and its SSL certificate | [Uptime](/docs/features/uptime) |
The first four open workspace pages, which need their own permissions.
The last two open pages of this site. Plugins for WordPress, Shopify,
Cloudflare, Google Tag Manager and more are in the
[install guides](/docs/install).
## Access
**Who can see this site**, member by member. Using it needs the
`members.manage` permission (Owner and Admin). Without it, the tab shows
a message instead of the list.
| Column | Shows |
|---|---|
| **Member** | Name and email |
| **Role** | The member's role, with "all sites" when they see every site |
| **Can see this site** | A checkbox, or **Always** for owners and admins |
- Owners and admins see every site. Their access can't be removed here.
- Other members see either all sites or only the sites granted to them.
- Ticking or clearing a checkbox saves at once.
- Clearing it for a member with "all sites" switches them to a list of
the workspace's other sites. From then on, new sites are not added to
their list automatically.
- You can't change a member who holds permissions you don't have.
A member without access to a site can't see it anywhere, including in
lists, exports and the API. See [Roles and permissions](/docs/teams/roles)
and [Teams](/docs/teams).
Through the API: `GET` and `PATCH /sites//access` (MCP:
`sites_access_list`, `sites_access_update`).
## Danger zone
- **Transfer**: send the site to another workspace. Someone with
permission there accepts. The site id, data and settings move with it,
so the snippet doesn't change.
- **Reset data** deletes events for a date range. It can't be undone.
- **Delete site**: confirm by typing the domain. The site is soft-deleted
for 7 days (collection stops), then permanently deleted.
---
Source: https://privatusanalytics.com/docs/dashboard/workspace-settings
# Workspace settings reference
> Manage workspace settings: members and roles, security and 2FA enforcement, SSO, billing, API tokens, webhooks, the audit log and white label branding.
| Page | What's there |
|---|---|
| **General** | Name, default timezone and currency, workspace id (`ws_…`), and **Delete workspace** (7-day soft delete) |
| **Members** | Members with role, site access, 2FA status and last activity. Invite, change role, remove, transfer ownership. See [Teams](/docs/teams) |
| **Roles** | Built-in roles and, on Business, [custom roles](/docs/teams/roles#custom-roles) |
| **Security** | Enforce 2FA (Business), allowed sign-in methods, session lifetime, IP allowlist for the app (Business), support access |
| **SSO** (Business) | [SSO](/docs/teams/sso) with SAML or OIDC, email domains and [SCIM](/docs/teams/scim) |
| **Billing** (sidebar) | Plan, usage meter with forecast and per-site breakdown, change plan, payment method, billing emails, tax id, invoices, cancel. See [Billing](/docs/billing) |
| **API tokens** | Create, view last use, revoke. See [Authentication](/docs/api/authentication) |
| **Webhooks** | Endpoints, events, signing secret, delivery log with retry. See [Webhooks](/docs/api/webhooks) |
| **Audit log** | Who did what, when. See [Audit log](/docs/teams/audit-log) |
| **White label** (Business) | Brand name, logo and accent color on shared dashboards, embeds and email reports, and an option to hide Privatus Analytics branding. See [White label](/docs/reports/white-label). Custom dashboard domains and email sender domains are not available |
The DPA, subprocessor list and SCCs are on the
[legal pages](/legal). See [Privacy & compliance](/docs/privacy).
---
Source: https://privatusanalytics.com/docs/dashboard/account-settings
# Account settings and preferences
> Manage your Privatus Analytics account: profile, password, passkeys, two-factor sign-in, theme, timezone, email preferences, data download and deletion.
These settings belong to you, not to a workspace. Click your name at the
bottom of the sidebar to open them. The page has three tabs: **Profile &
preferences**, **Security** and **Affiliate program** (see
[Affiliates](/docs/billing/affiliates)).
| Section | What's there |
|---|---|
| **Profile** | Name, email and timezone. A new email address takes effect once you open the confirmation link sent to it |
| **Security** | Password, authenticator app with recovery codes, passkeys, signed-in browsers, login history and connected Google and GitHub accounts. See [Security](/docs/dashboard/account-settings#security) |
| **Preferences** | Theme (system, light or dark), the page that opens after you log in (All sites or the last site you viewed), language, keyboard shortcuts on or off, reduce motion |
| **Email** | Product updates and tips, marketing emails such as onboarding tips, and the monthly newsletter. Alerts and email reports are set up per workspace, and account and security emails are always sent |
| **Your data** | Download a copy of your account data (JSON), or delete your account |
Profile, Preferences and Email are one form on the first tab, saved with
a single **Save** button. The language applies to the app while you're
signed in. Public pages use the language in their address.
## Security
**Account settings → Security** holds everything about how you sign in.
It only works in a signed-in browser, not with an API token.
| Section | What you can do |
|---|---|
| **Password** | Change your password by entering the current one and a new one of at least 10 characters. Your other browsers are signed out when it changes. If you signed up with a login link, Google or GitHub and have no password, use **Set a password by email** |
| **Authenticator app (2FA)** | Turn on a second step at login with an app such as 1Password, Authy or Google Authenticator. When it's on, the section shows how many recovery codes you have left, **New recovery codes** replaces them, and **Turn off** asks for your password |
| **Passkeys** | Add a passkey (Touch ID, Face ID, Windows Hello or a security key) under a name of your choice. A passkey signs you in and also counts as a second factor. The list shows when each was added and last used, with **Remove** |
| **Signed-in browsers** | Every browser where you're signed in, with its browser and operating system, IP address, when it signed in and when it was last active. **Sign out** ends that session. The one you're using is marked **This browser** |
| **Login history** | The last 30 days of sign-ins (up to 50): time, browser, IP address and whether the session is still active or signed out |
| **Connected accounts** | Connect or disconnect Google and GitHub as ways to sign in, with the date each was connected |
[Two-factor authentication](/docs/teams/two-factor) walks through setting
up an authenticator app, recovery codes and passkeys.
If a workspace you belong to requires two-factor authentication and you
have neither an authenticator app nor a passkey, a notice at the top of
the page names the workspace and asks you to set one up.
> **Note:** The IP addresses on this page are your own sign-ins, kept for
> account security. Visitors' IP addresses are never stored.
## Deleting your account
**Delete account** is at the bottom of the first tab. Type your email
address to confirm. Deleting removes your login, memberships, API tokens
and passkeys.
The form lists the workspaces you own. They are deleted with your
account, and their subscriptions are canceled. If one of them has other
members, either transfer ownership to another member first (the form
links to the Members page) or tick **Also delete the workspaces I own
that have other members**.
## API
Your profile, preferences and email settings are `GET` and
`PATCH /account` (MCP: `account_get`, `account_update`). The
[API reference](/docs/api) lists every input.
Changing the email address, turning marketing emails back on, deleting
the account and everything on the Security tab need a signed-in browser.
An API token can't do them.
---
Source: https://privatusanalytics.com/docs/dashboard/help-and-feedback
# Help and feedback
> How to ask the Privatus Analytics team a question or send feedback from the dashboard, what is sent with your message, and how the reply reaches you.
**Help & Feedback** is at the bottom of the left menu on every page of the
app. It opens a small form with a subject and a message. Use it to ask a
question, report a problem or tell us what you would like to see.
## What happens when you send
1. Your message is emailed to the Privatus Analytics team at
`hello@privatusanalytics.com`.
2. You get an email right away that confirms we received it.
3. A person replies to the email address of your account.
You can send up to 10 messages a day.
## What is sent with your message
So that you don't have to explain who you are, the message includes
details of your account:
- Your name, email address and user id.
- The page you sent the message from.
- Each workspace you belong to: its name and id, your role, the plan,
the subscription and billing state, this month's usage and its sites.
Nothing about your visitors is included, and the message is not stored in
the app. It lives in our mailbox, like any other email you send us.
## From the API or an AI agent
The same operation is in the JSON API (`POST /help.json`) and is the
`support_message_send` MCP tool. Both take `subject` and `message`. See
the [API reference](/docs/api).
---
Source: https://privatusanalytics.com/docs/metrics
# Analytics metrics and definitions
> How every metric in Privatus Analytics is calculated: visitors, visits, pageviews, bounce rate, visit duration, conversions, revenue, Web Vitals and uptime.
Every metric in the app has a tooltip with its definition and a link here.
See [How visitors are counted](/docs/metrics/how-visitors-are-counted) for
how visits and visitors are built without cookies.
| Metric | Definition |
|---|---|
| **Visitors** | Unique visitors per day, summed over the days in the range ("visitor-days"). Someone who visits on Monday and Tuesday counts twice in a weekly total. We can't deduplicate visitors across days, by design |
| **Visits** (sessions) | A series of hits from one visitor with less than 30 minutes of inactivity between them, ending at midnight UTC at the latest |
| **Pageviews** | Page loads plus single-page-app route changes |
| **Views per visit** | Pageviews ÷ visits |
| **Bounce rate** | The share of visits with **one pageview, no custom event, and less than 10 seconds of engaged time**. Stricter than most tools, so a reader who spends three minutes on one article isn't a bounce |
| **Visit duration** | Engaged (visible) time across the visit, from the first hit to the last engagement ping, capped at 4 hours. Shown as the **median** |
| **Time on page** | Visible engaged time on a page, as a median. Includes the last page of a visit (measured when the page is hidden or closed) |
| **Scroll depth** | The deepest scroll on a page view, in 10% bands. Reported as the median and as the share of views reaching 25, 50, 75 and 100% |
| **Entry page / exit page** | The first / last page of a visit |
| **Entry rate** | Visits that started on a page ÷ its pageviews |
| **Exit rate** | Exits from a page ÷ its pageviews |
| **Conversions** | Visits in which a goal was completed at least once |
| **Conversion rate** | Conversions ÷ visits |
| **Goal completions** | Every time the goal was completed, including repeats in one visit |
| **Revenue** | The sum of `revenue`, converted to the site currency at the ECB reference rate of the event's date. Original currencies are kept for breakdowns |
| **Live visitors** | Distinct visits with a hit in the last 5 minutes |
| **Web Vitals** | The 75th percentile (p75) of each metric (LCP, INP, CLS, FCP, TTFB), plus the share of good / needs improvement / poor by Google's thresholds |
| **Uptime** | Successful check intervals ÷ all intervals. An interval is down when most probe locations fail |
"Engaged time" needs the `engage` [module](/docs/tracker/modules), which
is on by default. Without it, visit duration and time on page are zero and
bounce rate counts every single-page visit without an event.
## Freshness
New events appear in reports within seconds (the target is 10 seconds at
the 95th percentile). The Live page reads the stream directly and updates
in about 2 seconds.
## Totals in breakdowns
In breakdown panels, the **share** column is each row's share of the rows
shown. Visit-based totals of a breakdown can add up to more than the
headline number: one visit can have several pages, and a visitor-day can
count under several countries if they travel.
## Filtering
Filters don't change definitions, only which visits and events count. See
[Filters and segments](/docs/dashboard/filters-and-segments).
## More
- [How visitors are counted](/docs/metrics/how-visitors-are-counted)
- [Why numbers differ from GA4](/docs/metrics/ga4-differences)
- [Channels](/docs/metrics/channels): how traffic sources are classified.
---
Source: https://privatusanalytics.com/docs/metrics/how-visitors-are-counted
# How visitors are counted
> How Privatus Analytics counts visitors and visits without cookies: a daily salted key computed in memory, random visit ids, and what that means for accuracy.
Privatus Analytics counts real visits and daily unique visitors without cookies,
without storing anything on the visitor's device, and with nothing that
persists or links across days.
## The daily visit key
1. Every UTC day we generate a random 256-bit **salt**. It exists only in
memory (Redis without persistence) and is destroyed shortly after the
day ends.
2. For each hit, `key = HMAC(daily_salt, site, IP, User-Agent)`, computed
in memory during the request.
3. The key is looked up in an in-memory store. If it's new, we start a
visit with a **random id** that is *not* derived from the key. Entries
expire after 30 minutes of inactivity and never outlive the UTC day.
4. The stored event has the random visit id and flags such as "first visit
today". The key, the IP and the raw User-Agent are never stored, logged
or queued.
After midnight the salt is gone, so nobody (including us) can recompute a
key or link a stored visit to a person or to another day.
## Visits, visitors and bounces
- **A visit** ends after 30 minutes without a hit, or at midnight UTC,
whichever comes first.
- **A visitor** is counted once per site per UTC day, on their first visit
of the day. The same browser the next day is a new visitor, so a weekly
total counts "visitor-days".
- **A bounce** is a visit with one pageview, no custom event and less than
10 seconds of engaged time.
Funnels, journeys, entry and exit pages, and the pages viewed before a
conversion are all built from these visits. Server-side events join the
same visits (see [server-side sessions](/docs/server-side/sessions)).
## What this means for accuracy
- Visitors can't be deduplicated across days, by design. Someone who
visits on Monday and Tuesday counts twice in a weekly total.
- Two people behind the same IP address with the same browser version
(an office network, for example) can be counted as one visitor.
- A visitor whose IP address changes during the day (switching from Wi-Fi
to mobile data) can be counted twice.
- A visit that spans midnight UTC is split into two.
See [why numbers differ from GA4](/docs/metrics/ga4-differences) for how
this compares with cookie-based tools.
## Your privacy policy
The IP address and User-Agent are processed transiently to build the daily
pseudonymous key, which you should mention in your privacy policy (see
[templates](/docs/privacy/policy-templates)).
---
Source: https://privatusanalytics.com/docs/metrics/ga4-differences
# Why numbers differ from Google Analytics 4
> Why Privatus Analytics and Google Analytics 4 report different numbers: consent banners, ad blockers, visitor and bounce definitions, bots and timezones.
It's normal for Privatus Analytics and GA4 to disagree, often by 10 to 50%. The main
reasons, roughly in order of size:
## 1. Consent banners
GA4 usually runs only after a visitor accepts cookies. Depending on your
banner and audience, 30 to 70% of visitors in Europe decline or ignore it.
With Consent Mode, GA4 models some of the missing data, which adds
estimates on top. Privatus Analytics doesn't need consent in most setups, so it
counts visitors GA4 never sees.
## 2. Ad and tracking blockers
Blockers stop GA4 more often than Privatus Analytics, but some blockers stop
Privatus Analytics too, so both tools can undercount. See
[Ad blockers](/docs/troubleshooting/ad-blockers).
## 3. Different definitions
| | Privatus Analytics | GA4 |
|---|---|---|
| Users / visitors | Unique **per day**, summed over the range | Unique over the whole range, based on a cookie or signed-in user id |
| Sessions / visits | 30 min inactivity, ends at midnight UTC, no campaign-based restarts | 30 min inactivity, can span midnight |
| Bounce | 1 pageview **and** no event **and** < 10 s engaged | Not "engaged": < 10 s, no conversion, one page |
| Engagement time | Visible time only | Foreground time |
Because visitors are counted per day, **weekly and monthly visitor totals
are higher** in Privatus Analytics than GA4's users for the same people. Compare
daily numbers, or pageviews, for a fair check.
## 4. Bot filtering
Both filter bots, with different lists. Privatus Analytics also filters data-center
traffic and headless browsers by default, and shows how many hits it
removed in the Overview's footer.
## 5. Timezones and date boundaries
Check that both tools use the same timezone. See
[Timezones](/docs/troubleshooting/timezones).
## 6. Where the script runs
Make sure both scripts are on the same pages. Single-page apps, excluded
paths, staging hostnames and [Google Tag Manager](/docs/install/google-tag-manager)
triggers often differ.
## A fair comparison
Pick one day, compare **pageviews** for a handful of top pages, with the
same timezone and hostname. Pageviews are the most comparable metric. The
remaining gap is mostly consent and blockers.
To bring your GA4 history over, see [Imports](/docs/reports/imports).
---
Source: https://privatusanalytics.com/docs/metrics/channels
# Traffic channels and sources
> How Privatus Analytics classifies every visit into a traffic channel from UTM parameters and the referrer, including AI assistants and custom channel rules.
Every visit gets one **channel**, from the landing page's campaign
parameters and the referrer. Rules are checked in this order, and the first
match wins.
| Channel | Rule |
|---|---|
| `paid_search` | A paid click id (gclid, msclkid…) or paid `utm_medium` from a search engine |
| `paid_social` | A paid click id or paid `utm_medium` from a social network |
| `paid_other` | Any other paid traffic. Paid mediums: cpc, ppc, paid, paidsearch, paid_search, cpm, cpv, display, banner, affiliate, paid_social, paidsocial |
| `email` | `utm_medium` containing email, or `newsletter`. Webmail: mail.google.com, outlook.live.com, outlook.office.com, mail.yahoo.com, mail.proton.me, app.fastmail.com, mail.aol.com |
| `ai_assistants` | Any `.ai` referrer, chatgpt.com, chat.openai.com, perplexity.ai, claude.ai, gemini.google.com, bard.google.com, copilot.microsoft.com, you.com, phind.com, meta.ai, grok.com, chat.mistral.ai, chat.deepseek.com, poe.com. Also utm_source chatgpt, chatgpt.com, openai, perplexity, claude, gemini, copilot, grok, deepseek, mistral |
| `organic_search` | google, bing, duckduckgo, yahoo, yandex, baidu, ecosia, qwant, startpage, brave, naver, seznam, ask, aol, search.yahoo, kagi, mojeek, sogou, so.com. Also utm_medium organic, search |
| `organic_social` | facebook.com, instagram.com, t.co, twitter.com, x.com, linkedin.com, lnkd.in, reddit.com, youtube.com, tiktok.com, pinterest.com, snapchat.com, threads.net, bsky.app, mastodon.social, news.ycombinator.com, whatsapp.com, telegram.org, discord.com, quora.com, tumblr.com, vk.com, weibo.com, producthunt.com. Also utm_medium social, social_media, sm, social-network |
| `internal` | A referrer on one of the site’s own domains |
| `referral` | Any other referrer or `utm_source` |
| `direct` | No referrer and no campaign parameters |
Click ids (`gclid`, `gbraid`, `wbraid`, `msclkid`, `fbclid`, `ttclid`,
`li_fat_id`, `twclid`, `dclid`) mark a visit as a paid click. They're kept
only as a yes/no flag. The id itself is discarded.
## Referrers, sources and campaigns
- **Referrer** is the hostname the visitor came from, with `www.` and `m.`
removed. The referrer's query string is never stored. Referrers from the
site's own domains are *internal* and don't start a new source.
- **UTM parameters** (`utm_source`, `utm_medium`, `utm_campaign`,
`utm_content`, `utm_term`) come from the landing page URL.
- **`ref`** is taken from `ref`, `via` or `source` query parameters.
A visit keeps the source it started with (first touch): later pages of the
same visit don't change it.
## Custom channel rules
**Site settings → Channels** lets you add your own rules, checked before
the built-in ones. Each rule has a channel name and one or more conditions
(all must match) on:
- `referrer_host`, `utm_source`, `utm_medium`, `utm_campaign`, `ref`, `path`
- with `is`, `contains`, `starts_with` or `regex` (case-insensitive).
Example: channel **Partners** when `ref starts_with partner-`. Rules apply
to new visits. The preview shows how recent traffic would be classified.
---
Source: https://privatusanalytics.com/docs/features
# Goals, funnels and more reports
> An overview of the reports beyond the dashboard: goals, funnels, journeys, campaigns, A/B experiments, Web Vitals, uptime, Search Console and AI crawlers.
The reports beyond the Overview:
| Report | What it answers |
|---|---|
| [Goals](/docs/features/goals) | How many visits converted, and from where? |
| [Funnels](/docs/features/funnels) | Where do people drop out of a multi-step flow? |
| [Journeys](/docs/features/journeys) | What do people do before and after a page? |
| [Campaigns](/docs/features/campaigns) | Which campaigns pay off, including cost, CPA and ROAS? |
| [Experiments](/docs/features/experiments) | Which variant converts better, and is it significant? |
| [Performance](/docs/features/performance) | How fast do pages feel to real visitors (Web Vitals)? |
| [Uptime](/docs/features/uptime) and [status pages](/docs/features/status-pages) | Is the site up, and how do we tell customers? |
| [Search Console](/docs/features/search-console) | Which search queries bring visits that convert? |
| [AI crawlers](/docs/features/ai-crawlers) | Which AI bots read the site, and which pages? |
| [Click maps](/docs/features/click-maps) | Which elements get clicked? |
---
Source: https://privatusanalytics.com/docs/features/goals
# Conversion goals
> Set up conversion goals for page visits, custom events, engagement, outbound clicks and file downloads, with goal values, counting options and path matching.
A goal marks a visit as **converted**. Goals are definitions applied when
you query, so creating or editing one updates all your history
immediately.
## Goal types
| Type | Completes when | Settings |
|---|---|---|
| **Page visit** | A page matching the path is viewed | Path, match type, optional hostname |
| **Event** | A custom event with that name fires | Event name, optional property conditions |
| **Engagement** | A page view reaches a scroll depth or time on page | Paths, scroll ≥ X% or time ≥ N seconds |
| **Outbound click** | An `Outbound Link` event to a domain or URL pattern | Domain or URL |
| **File download** | A `File Download` event | Extension or path |
Outbound click and file download goals use the events of the
[`auto` module](/docs/tracker/modules#auto-automatic-events). Engagement
goals need the `engage` module.
### Path matching
| Match | Example | Matches |
|---|---|---|
| Exact | `/thank-you` | `/thank-you` only |
| Starts with | `/docs/` | Everything under `/docs/` |
| Glob | `/blog/*/subscribed` | `*` stands for any characters |
| Regex | `^/(signup\|register)/done$` | A regular expression |
### Property conditions
Event goals can require properties, all of which must match: `is`,
`is_not`, `contains`, `gt`, `lt`. For example `plan is pro` and
`revenue gt 100`.
## Value
- **None**.
- **Fixed**: a value per completion, e.g. $50 per demo request.
- **From revenue**: the event's `revenue`, converted to the site currency.
## Counting
- **Once per visit** (default): conversions and conversion rate count
visits.
- **Every time**: completions count each repeat.
Both numbers are always available: *conversions* are visits with the goal,
*completions* are all the times it happened.
## The goal list and detail
The list shows each goal's completions, conversions, conversion rate,
value, trend and status. Duplicate, archive (hides it without deleting) or
create an alert from its menu.
A goal's detail page charts completions and conversion rate over time,
with breakdowns by channel, campaign, entry page, country and device, the
pages viewed before converting, the median time to convert
within the visit, and a link to build a funnel ending in the goal.
## Limits
Free: 10 goals per site. Paid plans: unlimited.
## API
Goals are a REST resource under `/sites//goals` and MCP tools
named `goals_*` (for example `goals_list`, `goals_create`). The
[API reference](/docs/api) lists every input.
---
Source: https://privatusanalytics.com/docs/features/funnels
# Conversion funnels
> Build conversion funnels from pages, events and goals, see how many visits complete each step and where the rest go, with breakdowns and time between steps.
## Build a funnel
**Funnels → New funnel** in the site menu (or Site settings → Funnels).
- **Steps:** 2 or more (up to 5 on Free, 10 on Pro, 20 on Business). Each
step is one of:
- a **page** (exact, starts with, glob or regex),
- an **event**, optionally with property conditions,
- an existing **goal**.
- **Names** for steps (optional).
- **Order:** *strict* (steps must happen in order) or *any* order.
- **Window:** within the same **visit**, or the same **day**.
Funnels are calculated at query time, so they work on your history too.
## The report
- A bar per step with the count, the conversion from the previous step,
the conversion from the first step, and the drop-off.
- The median time between steps.
- **Where they went instead**: click a drop-off to see the top next pages
of visits that didn't continue.
- Breakdown by channel, device, country, browser, OS, UTM source or UTM
campaign, and a comparison period.
## Limits
Free: 3 funnels per site, up to 5 steps. Pro: unlimited funnels, 10 steps.
Business: unlimited funnels, 20 steps.
---
Source: https://privatusanalytics.com/docs/features/journeys
# User journey paths
> Explore the most common paths visits take through your site as a flow diagram, forward from a page or back from a goal, without following any one person.
**Journeys** shows the most common paths through your site as a flow
diagram.
- **Forward:** pick a starting page (or *any entrance*) and see the three
steps that most often follow. Expand any node to go deeper.
- **Reverse:** pick an end page or a goal and see the three steps before
it.
- **Filters:** device and channel, plus the usual date range.
Journeys are built from visits: the order of pageviews inside a visit. A
visit never spans more than one day and isn't linked to other visits, so
you see how people move through the site without following any one person.
For large sites, paths are sampled over 100,000 visits, and the sample rate is
shown.
---
Source: https://privatusanalytics.com/docs/features/campaigns
# UTM campaign tracking
> Track UTM campaigns with visitors, conversions, revenue, cost, CPA and ROAS, build tagged links with the UTM builder, and clean up near-duplicate UTM values.
## Campaign report
**Campaigns** rolls UTM-tagged traffic up by campaign, then source and
medium, with visitors, visits, conversions, conversion rate, revenue and,
when you add costs, **cost**, **CPA** (cost per conversion) and **ROAS**
(revenue ÷ cost).
UTM values come from the landing page of each visit. Values are compared
as sent, so `Facebook` and `facebook` are reported as different values
(see [UTM hygiene](/docs/features/campaigns#utm-hygiene) below).
## Costs
In **Campaigns → Costs**, add spend per campaign per month by hand, or
upload a CSV with a header row and the columns `campaign`, `month`
(`YYYY-MM`), `amount` and, optionally, `currency` (the upload box shows
the exact template). A file can be up to 1 MB, and its first 5,000 rows
are imported.
Each campaign has one cost per month, so saving or importing the same
campaign and month again replaces the amount. The campaign name is
matched to `utm_campaign` without regard to case. Without a currency a
cost uses the site currency. Other currencies are converted to it for
the report. A month's cost is spread evenly over its days, so a report
for part of a month counts part of the cost.
## UTM builder
**Campaigns → UTM builder** builds a tagged link from a landing page URL
and the five UTM values, and shows the link as you type. The presets
(Newsletter, Social post, Paid search, Paid social, Print / QR and
Partner) fill in the usual source and medium.
**Check link** checks every value. Capital letters are lowercased and
spaces become underscores. A value that looks like **personal data** (an
email address, an id or a long number) is an error, and no link is built
until you remove it.
**Copy** the link, or use **Save to library** to keep it in the
[link library](/docs/features/campaigns#link-library).
## Link library
**Campaigns → Links** keeps the tagged links your team uses, so everyone
copies the same URL instead of typing UTM values again. Save one from the
UTM builder with **Save to library**, or start from **New link**.
| Field | Rules |
|---|---|
| **Name** | Required, up to 120 characters. A label for the library, not part of the URL |
| **Landing page URL** | Required. Starts with `https://` or `http://`, has no spaces and is up to 2,048 characters |
| **Source, Medium, Campaign, Content, Term** | Optional `utm_*` values. Saved in lowercase, with spaces turned into underscores, and cut at 255 characters |
| **Tags** | Optional, separated by commas. Saved in lowercase. A tag is cut at 40 characters and a link keeps its first 20 tags |
| **Notes** | Optional free text, for example where the link is used |
A link is not saved when its UTM values look like personal data (an
email address, an id or a long number).
### The tagged URL
Every saved link shows its **tagged URL**: the landing page URL with the
link's UTM parameters added. If the landing page URL already has one of
those parameters, the link's value replaces it. Other query parameters
are kept. **Copy** puts the tagged URL on your clipboard, from the list
or from the link's own page.
### Search and tags
The list shows the newest links first. Search matches the name, the
landing page URL and the campaign. Choose a tag from the menu, or click
a tag on a link, to see only the links with that tag.
### QR codes
**QR code** on a link shows a code for its tagged URL, for posters,
flyers and packaging. **Download SVG** saves it as a vector file, which
scales to any print size.
> **Tip:** Scan the code with a phone before you send it to print.
### Links and reports
A saved link is the landing page URL itself, not a redirect or a short
link, so the library does not count clicks. Visits that arrive through
the link appear in the
[campaign report](/docs/features/campaigns#campaign-report) under its
campaign, source and medium, like any other tagged visit. A link with a
campaign has a **See campaign performance** shortcut to that report.
Deleting a link only removes it from the library. Copies already in
emails, ads or print keep working, and their visits stay in your
reports.
Anyone who can view the site can open the library, copy links and
download QR codes. Saving, editing and deleting a link needs the
`content.write` permission (see
[roles](/docs/teams/roles#permission-matrix)).
## UTM hygiene
**Campaigns → UTM hygiene** checks the `utm_source`, `utm_medium` and
`utm_campaign` values in the selected period and lists:
- **Near-duplicate values** that differ only by case, spaces or
punctuation (`Facebook` vs `facebook`, `e-mail` vs `email`), with the
visits for each and a suggested spelling.
- **Values to tidy** that break the lowercase, underscore style (capital
letters, spaces, hyphens or other punctuation), with a suggested value.
- **Sources we don't recognize**: `utm_source` values that are not on the
list of well-known sources.
These are suggestions only, and nothing is changed for you. Fix the
links at the source (the UTM builder keeps new ones consistent). **Set
up alias** opens **Site settings → Channels**, where a
[custom channel rule](/docs/metrics/channels#custom-channel-rules) can
put the variants in the same channel.
## Tips
- Use `utm_source` for where (newsletter, linkedin), `utm_medium` for the
type (email, cpc, social) and `utm_campaign` for the campaign.
- `utm_medium` values like `cpc`, `paid` or `display` put visits in a paid
[channel](/docs/metrics/channels), and `email` puts them in Email.
- Never put personal data (emails, customer ids) in UTM parameters.
## API
The campaign report, costs, builder, hygiene check and link library are
REST resources under `/sites//campaigns` and MCP tools:
| Area | REST path | MCP tools |
|---|---|---|
| Campaign report | `/sites//campaigns` | `campaigns_list` |
| Costs | `/sites//campaigns/costs` | `campaign_costs_list`, `campaign_costs_create`, `campaign_costs_update`, `campaign_costs_delete`, `campaign_costs_import` |
| UTM builder | `/sites//campaigns/builder` | `campaigns_builder` |
| UTM hygiene | `/sites//campaigns/hygiene` | `campaigns_hygiene` |
| Link library | `/sites//campaigns/links` | `links_list`, `links_get`, `links_create`, `links_update`, `links_delete`, `links_qr` |
`links_qr` returns the QR code as SVG text. Its optional `size` is the
module size in pixels, from 2 to 20. The [API reference](/docs/api)
lists every input.
---
Source: https://privatusanalytics.com/docs/features/experiments
# A/B test experiment reports
> Report on A/B tests you run with your own tool: send the variant as a property, then compare conversion rates per variant with statistical significance.
Privatus Analytics doesn't run experiments. It reports on the ones you run with your
own code or tool (GrowthBook, Statsig, VWO, Optimizely…).
## Send the variant
Add the experiment and variant as properties on every hit:
```js
privatus.props({ experiment: 'checkout_2026_09', variant: 'b' })
```
Set them before the first pageview (use
[manual mode](/docs/tracker/manual-mode) if the variant is assigned after
load), or send them on the events that matter.
## The report
**Experiments** takes a property that holds the variant and a goal, and
shows per variant:
- visits and conversions,
- the conversion rate, with the difference from the control,
- the **statistical significance** of the difference.
Wait for significance before deciding, and decide the sample size up
front: stopping as soon as a result looks significant inflates false
positives.
Visits never outlive the UTC day, so an experiment that spans several
visits by the same person counts each visit separately.
---
Source: https://privatusanalytics.com/docs/features/performance
# Core Web Vitals monitoring
> Monitor real-user Core Web Vitals (LCP, INP, CLS, FCP and TTFB) at the 75th percentile by page, device, browser and country, with automatic regression notes.
Turn on the [`vitals` module](/docs/tracker/modules#vitals-web-vitals):
```html
```
## Scorecard
For each metric, the **75th percentile** (p75) over the range, the share
of page views rated good / needs improvement / poor, and the trend:
| Metric | Good | Poor |
|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2.5 s | > 4 s |
| INP (Interaction to Next Paint) | ≤ 200 ms | > 500 ms |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | > 0.25 |
| FCP (First Contentful Paint) | ≤ 1.8 s | > 3 s |
| TTFB (Time to First Byte) | ≤ 0.8 s | > 1.8 s |
These are Google's thresholds. Between good and poor is "needs
improvement".
## Breakdowns
By page (worst first, weighted by traffic), device, browser and country.
The [page detail](/docs/dashboard/page-detail) view shows Vitals for one
page by device.
## Regressions
Every day, each metric's p75 for the last 7 complete days is compared with
the 7 days before. A rise of more than 20% (with at least 20 samples)
adds an automatic [note](/docs/dashboard/notes) to your charts. Create a
**Vitals regression** [alert](/docs/reports/alerts) to be notified.
## Sampling and usage
Vitals are sampled on the server at the site's sample rate (Site settings
→ Tracking). The Free plan allows up to 10% and paid plans up to 100%. The
current rate is shown on the report. Web Vitals never count toward your
event usage.
---
Source: https://privatusanalytics.com/docs/features/uptime
# Uptime monitoring
> Monitor uptime with HTTP, keyword, TCP and DNS checks from four probe locations, plus SSL certificate and domain expiry, incident history and downtime alerts.
## Checks
Create checks from a site's **Uptime** page or across the workspace.
| Type | Checks that |
|---|---|
| **HTTP(S)** | A URL answers with the expected status code (`GET` or `HEAD`) |
| **Keyword** | A URL's response contains a keyword |
| **TCP** | A port accepts connections |
| **DNS** | A hostname resolves |
For each check choose the **interval**, a **timeout** and the **probe
locations**: US East, US West, EU Central and EU West.
| Plan | Checks | Fastest interval |
|---|---|---|
| Free | 3 | 5 minutes |
| Pro | 20 | 1 minute |
| Business | 100 | 30 seconds |
## When is a check down?
A check is **down** only when **most of its locations fail**, and a
confirmation re-check 30 seconds later fails too. One flaky location
never pages you. An incident opens when a check goes down and closes when
it recovers.
## Check detail
- Response time per location.
- Incident timeline: start, end, duration, which locations failed and the
error.
- For HTTPS: the **SSL certificate** issuer and expiry date. For the
domain: its **registration expiry** (from RDAP).
- 24-hour, 7-day and 30-day uptime and average response time.
Uptime % is the share of check intervals that were up.
## Alerts
Create **Uptime down/recovered** and **SSL or domain expiring in N days**
[alerts](/docs/reports/alerts), delivered by email, Slack, Teams, Discord,
webhooks, or PagerDuty and Opsgenie on Business. When a check linked to a
site goes down, the incident also appears as a [note](/docs/dashboard/notes)
on that site's charts.
## Allowing our probes
Probes identify themselves with this User-Agent:
```text
PrivatusUptime/1.0 (+https://privatusanalytics.com/docs/uptime)
```
The probe locations and their IP ranges are published as JSON at
[`/uptime/probes.json`](/uptime/probes.json), for firewall allowlists.
Uptime checks don't count toward event usage.
## Status pages
Show checks on a public [status page](/docs/features/status-pages).
---
Source: https://privatusanalytics.com/docs/features/status-pages
# Public status pages
> Create a public, branded status page for your uptime checks with a custom domain, incident updates and email subscribers. No cookies or third-party scripts.
A status page shows the current state and history of the checks you
choose, on a public page.
## Create one
**Status pages → New status page** in the workspace menu. Choose:
- a **name** and a **slug**: the page lives at
`https://privatusanalytics.com/status/`,
- which **checks** to show (each shows under its check name),
- your logo and colors,
- optionally a **custom domain** such as `status.example.com`: point a
CNAME at `privatusanalytics.com` and add the domain to the status page.
## Incident posts
Post updates during an incident ("Investigating", "Identified",
"Resolved") with a message. Posts appear on the page in a timeline.
## Subscribers
Visitors can subscribe by email. They confirm with a link (double
opt-in) and can unsubscribe from any email. Subscribers get your incident
posts. You can see and remove subscribers in the status page settings.
Their addresses are used only for these notifications.
## Privacy
Status pages set no cookies and don't load third-party scripts.
---
Source: https://privatusanalytics.com/docs/features/search-console
# Google Search Console and Bing integration
> Connect Google Search Console and Bing Webmaster Tools to see queries, clicks, impressions and rankings next to the visits and conversions each page brings.
## Connect
Open the site's **Search** page (also linked from **Site settings →
Integrations**): sign in with Google, grant **read-only** access, and choose the
property that matches the site.
### Bing Webmaster Tools
Bing connects with your own Bing Webmaster API key:
1. Sign in to [Bing Webmaster Tools](https://www.bing.com/webmasters) with
the account where the site is verified. Only sites verified in that
account appear in the list.
2. Click **Settings** (the gear icon) > **API access** > **API Key**, and
copy your key.
3. On the **Search** page, switch to the **Bing Webmaster Tools** tab, paste
the key and click **Load sites**.
4. Pick the site and click **Connect**.
We store your key encrypted and only use it to read search performance for
the site you choose. It is never shown again or returned by the API. You can
revoke it in Bing Webmaster Tools at any time. If Bing stops accepting it,
the Search page asks you to paste a new one, and connecting again with a new
key replaces the old one. Each site has its own key.
Over the [API](/docs/api) or MCP, send `bing_api_key` with `provider: bing`
to `POST /sites/{site}/search/properties` (list the sites) and
`POST /sites/{site}/search/connections` (connect).
We sync the data daily. Search engines publish it with a delay of two to
three days, so the most recent days are always empty, and the report shows a
warning for them.
Disconnect at any time. The synced data, and for Bing your stored API key,
is deleted with the connection.
## The report
- **Queries, pages, countries and devices**, each with clicks,
impressions, click-through rate and average position.
- **Landing page join:** each page's search clicks next to its Privatus Analytics
visits, bounce rate and conversions. This is an aggregate join by page
URL. Nothing about individual people is involved. It answers "which
queries lead to pages that convert?".
- **Opportunities:** queries with many impressions but a low click-through
rate, and pages ranking in positions 8 to 20 that could reach page one.
### Bing reports queries and pages by week
Bing gives daily numbers only for the whole site. The totals and the
**Clicks by day** chart use those. Bing reports queries and pages once a
week, and only the top ones, so those tables add up to less than the
totals, and a short period can show fewer rows than you expect.
## Why search numbers don't match visits
Search clicks come from Google or Bing, visits from your site. They differ
because of blockers, visitors who leave before the page loads, bots, and
the search engines' own anonymised-query filtering.
---
Source: https://privatusanalytics.com/docs/features/ai-crawlers
# AI crawler tracking from server logs
> See which AI crawlers like GPTBot, ClaudeBot and PerplexityBot, and which search bots, read your pages, from server or CDN logs with no IP addresses stored.
Crawlers don't run JavaScript, so the tracker never sees them. The **AI
crawlers** report is built from your **Cloudflare analytics** or your
**server or CDN logs** instead.
## What you see
Daily hits per crawler and per path, grouped into:
| Category | Examples |
|---|---|
| **AI** (training, AI search and assistants) | GPTBot, ChatGPT-User, OAI-SearchBot, ClaudeBot, Claude-User, PerplexityBot, Google-Extended, Applebot-Extended, Bytespider, CCBot, Amazonbot, Meta-ExternalAgent |
| **Search** | Googlebot, Bingbot, DuckDuckBot, YandexBot, Baiduspider, Applebot |
| **SEO tools** | AhrefsBot and similar |
| **Other** | Any other bot |
Use it to decide what to allow in `robots.txt`, to see which pages AI
assistants fetch when answering users, and to spot crawlers ignoring your
rules.
AI assistants sending *people* to your site is different: those visits
appear in the **AI assistants** [channel](/docs/metrics/channels).
## Connect Cloudflare
If your site is behind Cloudflare, this is the quickest way. There is
nothing to deploy.
1. Open the site's **AI crawlers** page and choose **Connect Cloudflare**.
2. Approve read-only access on Cloudflare's own consent page. We ask for
two permissions: listing your zones and reading zone analytics.
3. Pick the zone that serves the site.
The first import brings in up to 30 days, as far back as your Cloudflare
plan keeps analytics. After that the report updates every three hours, and
**Sync now** imports straight away. Only requests to the site's own
hostname (with and without `www`) are counted, so other subdomains in the
same zone stay out.
Good to know:
- Cloudflare samples analytics on busy zones, so hit counts are close
estimates, not exact log counts.
- Each day keeps the 10,000 busiest crawler and path pairs.
- Use Cloudflare sync or log shipping for a site, not both, or the same
hits are counted twice.
- **Disconnect** removes the hits imported from Cloudflare and revokes our
access. Hits from your own logs stay.
Over the API and MCP, `crawlers_cloudflare_zones`,
`crawlers_cloudflare_connect`, `crawlers_cloudflare_sync` and
`crawlers_cloudflare_disconnect` do the same once the Cloudflare account
has been approved in the browser.
## Connect your logs
Not on Cloudflare, or want exact counts? Send logs yourself.
Logs are sent with the site's **server ingest key** (Site settings →
Tracking → Server ingest key) to the endpoint shown on the site's **AI
crawlers** page. Connectors:
- **Cloudflare:** a Worker (or Logpush job) that forwards request logs.
- **Vercel** and **Netlify:** a log drain.
- **Nginx or Caddy:** a small log shipper that posts lines in the
"combined" log format.
## Privacy
Log lines contain visitors' IP addresses, so we parse them immediately and
keep only the day, the path, the status code and the crawler's name.
Lines from normal browsers are skipped. The IP, the referrer and any user
field are discarded as soon as a line is split and are never stored.
The Cloudflare sync never receives IP addresses at all. Cloudflare sends
us request counts grouped by User-Agent and path. We use the User-Agent
to name the crawler and store only the name, the path and the daily count.
---
Source: https://privatusanalytics.com/docs/features/click-maps
# Click maps without session recording
> Count clicks per element with privacy-friendly click maps built from CSS selectors. No mouse movement, coordinates, form input or session replay is collected.
Turn on the [`clicks` module](/docs/tracker/modules#clicks-click-maps):
```html
```
For each click, the module sends a short CSS selector of the clicked link,
button or control, for example `header > nav.main > a#pricing`. The report
lists the most clicked elements per page for the selected date range, with
each element's share of clicks.
What it never collects: mouse movement, scrolling paths, coordinates,
element text, form input or screenshots. There's no session replay.
## Name your targets
Generated ids and classes (those with three or more digits in a row) are
left out of selectors. For stable names, add `data-privatus-click` to
important elements:
```html
…
```
Click-map hits don't count toward usage.
---
Source: https://privatusanalytics.com/docs/reports
# Sharing, reports and exports
> Share dashboards, schedule email reports, set up alerts, export raw or aggregated data, and import your history from Google Analytics 4 and other tools.
| Page | For |
|---|---|
| [Sharing](/docs/reports/sharing) | Public and password links, embeds, badges and wallboards |
| [Email reports](/docs/reports/email-reports) | Scheduled daily, weekly or monthly summaries |
| [Alerts](/docs/reports/alerts) | Thresholds, anomalies, goals, uptime, SSL, Vitals and usage |
| [Exports](/docs/reports/exports) | Aggregated tables, raw events and warehouse sync |
| [Imports](/docs/reports/imports) | GA4, Universal Analytics and other tools' history |
| [White label](/docs/reports/white-label) | Your brand name, logo and color on shared dashboards and email reports (Business) |
---
Source: https://privatusanalytics.com/docs/reports/sharing
# Share analytics dashboards
> Share a read-only analytics dashboard with a public or password-protected link, embed it in an iframe, show it on a TV wallboard or add a visitor counter badge.
**Site settings → Sharing** creates links to a read-only dashboard for
people without an account.
## Link types
| Type | Who can open it | Plan |
|---|---|---|
| **Public** | Anyone with the link | All |
| **Password** | Anyone with the link and the password | All |
| **Embed** | Pages that embed it in an `