Skip to content

Counting correctly

The six integration mistakes that make a dashboard lie, how each one looks in your data, and the rule that prevents it.

5 min read Updated Suggest an edit

DataVisitors counts what it receives. It cannot repair an integration that sends the wrong thing. This page lists the mistakes that make a dashboard lie, how to recognise each one in your own data, and the rule that prevents it.

The measured case that produced this page (2026-08-29): one person loaded a board a few times. The dashboard showed 491 page views and 469 visitors. The browser tracker had sent 23 page views for 1 visitor — the truth. A server-side integration had sent 467 page views under 467 different visitor ids.

The one rule

One visitor is one stable anonymous_id. One visit is one stable session_id. One page load is one sg_page_view.

Every mistake below breaks one of those three sentences.

Mistake 1 — a new id on every request

What happens. A server-side SDK builds a client object per request and the object mints a fresh anonymous_id. Every request becomes a new visitor and a new session. Visitors equal page views.

How it looks. Filter by sdk_name. For the server SDK, visitors equals page views equals sessions. Bounce rate is 100%. Every session has one event.

The rule. The id belongs to the VISITOR, not to the process. A web backend derives it per request from the visitor — a first-party cookie it sets, or a keyed hash of address + user agent that rotates daily — and supplies it to the SDK. In the PHP SDK that is the anonymousId: / sessionId: constructor input. Read the ids back (anonymousId(), sessionId()) and persist them.

Mistake 2 — one id for every visitor

What happens. A server-side SDK is given a storage directory (storageDir, queueDir, a singleton) so its identity "survives restarts". On a web server that identity is shared by everyone. Every visitor is the same visitor.

How it looks. visitors is 1 for any traffic. One session runs for days.

The rule. Stored identity is for ONE-USER processes: a NativePHP desktop or mobile app, a CLI tool. Never on a web server.

Mistake 3 — counting the same load twice

What happens. The browser tracker (sg.js) is installed AND the backend sends sg_page_view for the same request. Each load is two page views from two visitor ids, because the tracker keeps its id in localStorage, which the server can never read.

How it looks. Two sdk_name values both report page views for the same paths, at the same times, a few hundred milliseconds apart.

The rule. When the tracker is installed, the browser OWNS page views and visitors. The server sends backend events only: track('order_paid', ...) from a webhook or a job. Turn off server-side page views in the same configuration that turns the tracker on, so the two can never both be true.

Mistake 4 — a response that is not a page

What happens. A backend hooks "every request" and sends a page view for each one: a JSON API answer, a polling endpoint, a redirect, an asset, a HEAD. A leaderboard that polls every 25 seconds sends a page view every 25 seconds per open tab.

How it looks. One path dominates the Pages table. Its count grows while nobody clicks.

The rule. A page view is a GET that answered 2xx with text/html. Nothing else. Put the check next to the send, and test it with a JSON response and a redirect.

Mistake 5 — an operator counted as traffic

What happens. The site owner's own console sessions are sent as product traffic. With one owner and little traffic, the owner IS the dashboard.

The rule. Skip requests from an authenticated operator or admin on both halves: do not render the tracker tag for them, and do not send server-side events for them. DataVisitors also refuses known crawlers at the ingest (bot rejections — the same public-domain list Umami uses, about 200 patterns, plus headless browsers and HTTP libraries, checked against 626 known crawlers and 555 real browsers), but it cannot know who your operator is.

Mistake 6 — a bad id that refuses the whole batch

What happens. A supplied id contains a space or is too short. The server refuses the batch that carries it, and every other event in the batch is lost with it.

The rule. Every id matches ^[A-Za-z0-9_-]{8,64}$. The PHP SDK replaces a supplied id that fails the shape with a minted one so the batch still lands; other integrations must validate before sending.

Check your own integration in five minutes

  1. Open one page in a private browser window. Reload it three times.
  2. On the dashboard, filter or query by sdk_name. Expect: the browser tracker reports 4 page views, 1 visitor, 1 session. Expect NO server-side page views while the tracker is installed.
  3. Leave the page open for two minutes without clicking. Expect: the page-view count does not move. If it does, a poll is being counted (mistake 4).
  4. Close the browser, wait 31 minutes, open the page again. Expect: 2 sessions, still 1 visitor.
  5. Sign in as the operator and click through the console. Expect: no new page views at all (mistake 5).

A healthy day has visitors well below page views, and sessions between the two.

What DataVisitors does and does not do for you

  • The ingest refuses malformed ids, unknown fields, oversized events, known crawlers, spam referrers, prerendered pages, blocked countries and paths, and disallowed hostnames. Each refusal is typed in the 202 response.
  • The ingest de-duplicates a retried batch by event_id.
  • The ingest does NOT merge two ids into one visitor, and it does NOT drop a page view because another SDK sent one for the same load. It has no way to know they are the same person. That knowledge exists only in your integration, which is why this page exists.

Related: SDKs for each SDK's identity input, and the event protocol reference for the wire contract.