⚡ ⚡ Learn how Investing.com uses Jitsu to move data faster

First-party analytics for publishers: ad blockers, Safari and server-side events

Your server logs and your analytics tool disagree on page views, and the gap is wider on some browsers than on others. On Safari the reader count is off too, because a reader who returns after a week arrives with a new ID.

TL;DR

  • The script and collection endpoint load from a subdomain such as events.example.com.
  • An ID endpoint on your own domain sets the anonymous ID as an HttpOnly cookie, which Safari's 7-day cap on script-set cookies does not cover.
  • Your server reports page renders to Jitsu, a function keeps one in ten and tags each event by where it came from, and a BigQuery query returns the share with no browser page event, per browser.

How big the gap is

eyeo's 2026 Ad-blocking Report estimates that ad-blocking users made up 30% of the internet population in Q4 2025, and notes that these tools block analytics trackers as well as ads. Its 2025 country table puts the ad-blocking rate at 33% in the United States, 27% in the UK and 31% in Germany.

The loss on a given site depends on its audience: Plausible measured Google Analytics missing 58% of visitors during a tech-heavy traffic spike in 2021.

Safari adds a second gap. Its Intelligent Tracking Prevention caps cookies set by JavaScript at 7 days (WebKit, ITP 2.1), and Jitsu's anonymous ID is one of those cookies unless the ID endpoint below sets it.

Your own subdomain and the ID endpoint

On the Business and Enterprise plans, point a subdomain such as events.example.com at cname.jitsu.com with a CNAME record (docs, feature page). The script and the collection endpoint then load from your domain, and browser filter rules written for jitsu.com hostnames do not match them. Blockers that resolve the CNAME, at the DNS level or in the browser, can still block the script, and the query below counts those readers with the rest. Free workspaces collect on a d.jitsu.com subdomain, and self-hosted Jitsu puts your own domain in front with Cloudflare or Caddy.

Setting data-id-endpoint below turns on Jitsu's ITP mitigation: a route on your own web server sets the anonymous ID as an HttpOnly cookie. It must answer on the site's own domain, and a subdomain works only if it resolves to the same IP address as the main domain.

This snippet also skips the automatic page event and sends one with the article ID and the request ID your server rendered into the page.

<script>
  window.jitsuLoaded = function (jitsu) {
    jitsu.page({ article_id: "a-2231", request_id: "9f1c2e7a" });
  };
</script>
<script async src="https://events.example.com/p.js"
  data-init-only="true"
  data-onload="jitsuLoaded"
  data-id-endpoint="/api/jitsu-id"></script>

Measuring what your analytics still misses

With the page event carrying its request_id, the server side, a sampling function and one query do the rest. Two events do the measuring; the other 32 in a publisher's plan are in the tracking plan CSV.

EventSent byPropertiesPersonal data
pageBrowser, Jitsu script on your subdomainarticle_id, request_idPersonal query parameters stripped in a function
page_renderedYour server or edge, HTTP APIrequest_id, article_id, path; context.ip, context.userAgentIP and user agent in context

With full-page CDN caching, generate request_id and send page_rendered per request at the edge, in an edge function that writes the ID into the HTML. A request_id baked into cached HTML is shared by every reader of that copy, and the query undercounts loss.

The server side

Your server or edge sends page_rendered to the HTTP API with a server-to-server write key (docs; the server-side tracking guide covers the general case). The request comes from your server, not the reader's browser, so copy the reader's IP and user agent into context yourself.

Read the reader's anonymous ID from the __eventn_id cookie on your domain so both events share it, and turn on Strict Mode for this stream, so only requests with a valid write key are accepted.

curl --location 'https://events.example.com/api/s/track' \
--header 'Content-Type: application/json' \
--header 'X-Write-Key: keyId:keySecret' \
--data-raw '{"type":"track","event":"page_rendered","anonymousId":"7c1e9a52-0d4b-4a8e-9f3a-2b6f1c9d8e01","properties":{"request_id":"9f1c2e7a","article_id":"a-2231","path":"/news/a-2231"},"context":{"ip":"203.0.113.7","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15"}}'

Exclude crawlers and prefetch requests before sending it: they never run the page script, and counted here they read as blocked readers.

Sampling the renders

The function hashes request_id and keeps the same share of IDs every time, 10 by default. It also tags every page and track event with collection_method (server or client). Set SERVER_SAMPLE_PERCENT on the connection, and attach the function to both connections into the warehouse, the browser stream's and the server stream's.

import { JitsuFunction } from "@jitsu/protocols/functions";
import { AnalyticsServerEvent } from "@jitsu/protocols/analytics";

export const config = {
  slug: "blocker-loss-sample",
  name: "Blocker loss sample",
  description: "Keeps a fixed sample of server-side page_rendered events and tags events with how they were collected",
};

type Props = {
  // Share of server-side page renders to keep, 0-100. Default 10.
  SERVER_SAMPLE_PERCENT?: string;
};

// FNV-1a hash: the same request_id always lands in the same bucket
function bucket(id: string): number {
  let hash = 0x811c9dc5;
  for (let i = 0; i < id.length; i++) {
    hash ^= id.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return (hash >>> 0) % 100;
}

const blockerLossSample: JitsuFunction<AnalyticsServerEvent, Props> = async (event, { log, props, source }) => {
  if (event.type === "track" && event.event === "page_rendered") {
    const requestId = event.properties?.request_id;
    if (typeof requestId !== "string" || requestId === "") {
      log.warn(`page_rendered ${event.messageId} has no request_id, dropped`);
      return "drop";
    }
    const configured = Number(props.SERVER_SAMPLE_PERCENT ?? "10");
    const percent = Number.isFinite(configured) ? configured : 10;
    if (bucket(requestId) >= percent) {
      return "drop"; // outside the sample, and no fetch has run, so not billed
    }
  }
  if (event.type === "page" || event.type === "track") {
    event.properties = {
      ...event.properties,
      collection_method: source?.type === "s2s" ? "server" : "client",
    };
  }
  return event;
};

export default blockerLossSample;

The query

This one is BigQuery. Each property is its own column there, so the query reads request_id directly, and context_user_agent from the server events you sent.

-- BigQuery: sampled server renders with no client page event, by browser, last 7 days
WITH server_renders AS (
  SELECT
    request_id,
    ANY_VALUE(CASE
      WHEN REGEXP_CONTAINS(context_user_agent, r'Edg/') THEN 'Edge'
      WHEN REGEXP_CONTAINS(context_user_agent, r'Firefox/') THEN 'Firefox'
      WHEN REGEXP_CONTAINS(context_user_agent, r'Chrome/') THEN 'Chrome'
      WHEN REGEXP_CONTAINS(context_user_agent, r'Safari/') THEN 'Safari'
      ELSE 'Other'
    END) AS browser
  FROM `your_dataset.events`
  WHERE type = 'track'
    AND event = 'page_rendered'
    AND timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
  GROUP BY request_id
),
client_pages AS (
  SELECT DISTINCT request_id
  FROM `your_dataset.events`
  WHERE type = 'page'
    AND request_id IS NOT NULL
    AND timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 8 DAY)
)
SELECT
  s.browser,
  COUNT(*) AS server_renders,
  COUNTIF(c.request_id IS NULL) AS no_client_event,
  ROUND(SAFE_DIVIDE(COUNTIF(c.request_id IS NULL), COUNT(*)), 3) AS share_missing
FROM server_renders AS s
LEFT JOIN client_pages AS c ON c.request_id = s.request_id
GROUP BY s.browser
ORDER BY server_renders DESC;

share_missing is the share of sampled server renders with no client page event: readers whose blocker stopped the script and readers who left before the script loaded. In the browser split, every iOS browser reports as Safari.

Run it weekly and track share_missing by browser over time.

Questions people ask

Does the ID endpoint change share_missing? It does not. share_missing compares page renders with page events, which the ID endpoint does not touch; what the ID endpoint changes is how many unique readers you count on Safari.

Do I still need consent for first-party or server-side tracking? Yes, on your own domain and on the server alike. Send page_rendered only for readers whose recorded consent allows analytics, so readers who declined stay out of the measurement instead of showing up as blocked.

Does sending page renders from the server double the bill? No. The sampling function keeps one render in ten by default and drops the rest before any fetch call runs, so the server stream adds a tenth of your page views.

Get started with Jitsu

Sign up, write request_id into your article page template, then send page_rendered from your server and attach the sampling function; the first weekly comparison is ready seven days after page_rendered starts. Pricing lists what each plan includes. Back to the media and publishing guide for the subscription and warehouse setups.

Subscribe to the Jitsu newsletter
Occasional product updates when we ship something worth knowing about. No spam.