⚡ ⚡ 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. Ad blockers and Safari's tracking prevention account for part of it. First-party analytics on Jitsu recovers part of that: serve Jitsu's script and collection endpoint from your own subdomain, and send the events that matter (page renders, sign-ups, subscriptions) from your servers, where no browser extension sees them.

This page is part of the media and publishing guide; the general case is in the server-side tracking guide.

TL;DR

  • Custom domains are on the Business and Enterprise plans: a CNAME from your subdomain to cname.jitsu.com.
  • Server-to-server events reach Jitsu without passing through the browser.
  • Safari caps cookies set by JavaScript at 7 days; Jitsu's ID endpoint sets the anonymous ID as an HttpOnly cookie instead.
  • A function keeps one in ten server page renders by default and drops the rest before any fetch, so they are not billed.
  • The BigQuery query on this page counts server renders with no matching browser page event, split by browser.

Who this is for

Analytics and audience-development leads at publishers whose server logs and analytics tools disagree on page views, and the engineers who own the site's templates and edge.

The problem

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 one tech-heavy traffic spike in August 2021, against under 10% on food and lifestyle sites in an earlier study.

Safari adds a second gap. Its Intelligent Tracking Prevention caps cookies set by JavaScript at 7 days (WebKit, ITP 2.1) and caps cookies set in third-party CNAME-cloaked responses at 7 days as well (WebKit, CNAME cloaking). A Safari reader who returns after more than a week gets a new anonymous ID and counts as a new reader.

The events

EventEmitted fromRequired propertiesPII handling
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 sent explicitly, as server-to-server events require; identifiers only with recorded analytics consent
signed_upBrowser, app (identify follows in the browser)method, placementNone
subscription_startedBilling backend, HTTP APIsubscription_id, plan, value, currency, ad_consentNo email
email_openedPixel API (no JavaScript)campaign_id, list_idNone; Apple Mail preloads images, so opens read high

The request_id is the join key. Your server generates it per page render, sends it in page_rendered, and writes it into the page so the browser sends the same value.

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. Every event with property types is in the media tracking plan (CSV).

Sending them

A custom domain for the script and collection 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). Free workspaces collect on a d.jitsu.com subdomain. Self-hosted Jitsu puts your own domain in front of it with Cloudflare or Caddy.

This snippet loads the script from that subdomain, skips the automatic page event, and sends one with the article and request IDs your server rendered into the page.

data-id-endpoint turns on Jitsu's ITP mitigation: an endpoint on your own web server sets the anonymous ID as an HttpOnly cookie, which ITP's 7-day cap on script-set cookies does not cover. The endpoint must answer on the same domain as the site; a subdomain works only if it resolves to the same IP address as the main domain.

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

Server-side page renders

Your server or edge sends page_rendered to the HTTP API with a server-to-server write key. Server-to-server events do not take the IP address and user agent from request headers, so pass the reader's in context (docs).

Read the reader's anonymous ID from the __eventn_id cookie on your domain so both events share it. 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"}}'

Send page_rendered only for readers whose recorded consent allows analytics, or send it without anonymousId and context.ip for the rest.

Exclude crawlers and prefetch requests at the source, before sending page_rendered. They never run the page script, and counted here they read as blocked readers.

Consent

Consent rules apply to a first-party domain and to server-side events the same way. Wire your consent platform's callback into jitsu.configure({ privacy }) as the consent docs show: dontSend until consent, or disableUserIds with ipPolicy: "stripLastOctet" for limited collection before consent.

Server-side events follow the consent state your backend recorded; Jitsu sees only what you send. Jitsu's DPA and SOC 2 details are on the security page.

Shaping them before they land

Sending every page twice doubles the active events for page views. The function keeps a fixed sample of server renders instead: it hashes request_id, keeps the same share of IDs every time (10 by default), and drops the rest before any fetch, so the dropped renders are not billed.

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.

Build and test it with jitsu-cli; the functions docs cover the runtime. The function editor in the Jitsu UI takes JavaScript, which is what jitsu-cli build outputs.

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;

Querying it

BigQuery, against the default events table. Properties land as root columns, so request_id is a column of its own, and server events carry context_user_agent because you sent it.

-- 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. It includes readers whose blocker stopped the script and readers who left before the script loaded. Readers who declined consent count only if you send page_rendered for them without identifiers.

The browser split comes from a coarse user-agent match, and every iOS browser reports as Safari.

What this fixes and what it does not

ChangeWhat it fixesWhat it does not fix
Custom domainBrowser filter rules written for jitsu.com hostnames stop matchingDNS blockers that follow CNAMEs, such as Pi-hole with its default settings, still trace the subdomain to cname.jitsu.com, and some DNS blocklists include jitsu.com. Filter lists can match a script path or request payload on any domain. Safari still caps cookies set in CNAME-cloaked responses at 7 days
Server-side eventsNo browser extension sees themThey carry only what your server knows: no scroll depth, engaged time or video play
ID endpointThe anonymous ID outlives Safari's 7-day cap on script-set cookiesReaders who clear cookies or switch devices still get a new ID
All threeMore of the readers you are allowed to measure reach the warehouseConsent. A reader who declined analytics is still out

Destinations

Send both streams to the warehouse: BigQuery here, or ClickHouse, Snowflake, Redshift, Postgres or MySQL. The browser stream also goes to product analytics (Mixpanel, Amplitude or PostHog) with the same function. Subscription events from the server feed Google Ads and Meta Ads, covered on the subscriber conversions page.

Example implementation (illustrative)

Illustrative example: not a Jitsu customer. A national news site serves full-page cached HTML from a CDN that runs edge functions, and loads its events into BigQuery.

  • Edge: an edge function writes a new request_id into each response and sends page_rendered for every request, with identifiers only when the consent cookie allows analytics.
  • Browser: the script loads from an events. subdomain with data-init-only, data-onload and data-id-endpoint, as in the snippet above.
  • Connections: both streams go to BigQuery, and each connection carries blocker-loss-sample with SERVER_SAMPLE_PERCENT=10.
  • Reporting: the blocker-loss query runs each week, and the team compares share_missing by browser before and after the move to the custom domain.

Where Jitsu does not fit

  • Custom domains on Jitsu Cloud need the Business or Enterprise plan; see pricing.
  • A blocked script still loses the browser-only events: scroll depth, engaged time and video play.
  • Jitsu has no measurement of its own for blocked traffic. The query above is how you get the number.
  • Self-hosted Jitsu has no built-in custom-domain setup; Cloudflare or Caddy does that part.
  • Server-side page renders add active events: at the default sample they add a tenth of your page views.

Questions people ask

Does a custom domain stop ad blockers from blocking Jitsu? A custom domain stops browser filter rules written for jitsu.com hostnames from matching. DNS blockers that follow CNAMEs, such as Pi-hole with its default settings, still trace the subdomain to cname.jitsu.com and block it when their list includes jitsu.com, as some DNS blocklists do. Filter lists can also match the script path or the request payload on your own domain, and server-side events are the part no blocker sees.

Which Jitsu plans include custom domains? Custom domains are on the Business and Enterprise plans of Jitsu Cloud. Free workspaces collect on a d.jitsu.com subdomain.

Does Safari's 7-day cookie cap apply on a custom domain? Safari's cap applies on a custom domain too. It covers cookies set by JavaScript and cookies set in third-party CNAME-cloaked responses, both capped at 7 days. Jitsu's ID endpoint, served by your own web server on the same domain and IP address, sets the anonymous ID as an HttpOnly cookie that the cap does not cover.

Do I still need consent for first-party or server-side tracking? Yes. Consent rules apply to a first-party domain and to server-side events the same way. Jitsu's JavaScript library takes the consent platform's choices through its privacy settings, and server-side events carry the consent state your backend recorded.

How do I measure what ad blockers remove on my site? Send a server-side page_rendered event with a request ID for a sample of page renders, send the same request ID with the browser's page event, and count the server renders that have no matching browser event. The BigQuery query on this page does that, split by browser.

Get started with Jitsu

Get started on the free plan with 200k active events a month, or book a demo to set up custom domains and server-side collection on Business or Enterprise.

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