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
| Event | Emitted from | Required properties | PII handling |
|---|---|---|---|
page | Browser, Jitsu script on your subdomain | article_id, request_id | Personal query parameters stripped in a function |
page_rendered | Your server or edge, HTTP API | request_id, article_id, path; context.ip, context.userAgent | IP and user agent sent explicitly, as server-to-server events require; identifiers only with recorded analytics consent |
signed_up | Browser, app (identify follows in the browser) | method, placement | None |
subscription_started | Billing backend, HTTP API | subscription_id, plan, value, currency, ad_consent | No email |
email_opened | Pixel API (no JavaScript) | campaign_id, list_id | None; 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
| Change | What it fixes | What it does not fix |
|---|---|---|
| Custom domain | Browser filter rules written for jitsu.com hostnames stop matching | DNS 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 events | No browser extension sees them | They carry only what your server knows: no scroll depth, engaged time or video play |
| ID endpoint | The anonymous ID outlives Safari's 7-day cap on script-set cookies | Readers who clear cookies or switch devices still get a new ID |
| All three | More of the readers you are allowed to measure reach the warehouse | Consent. 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_idinto each response and sendspage_renderedfor every request, with identifiers only when the consent cookie allows analytics. - Browser: the script loads from an
events.subdomain withdata-init-only,data-onloadanddata-id-endpoint, as in the snippet above. - Connections: both streams go to BigQuery, and each connection carries
blocker-loss-samplewithSERVER_SAMPLE_PERCENT=10. - Reporting: the blocker-loss query runs each week, and the team compares
share_missingby 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.