Proxying the collector
The tracker talks to exactly one origin, set by data-api. Point it at a
hostname you control and every request becomes first-party.
<script defer data-site="pk_live_xxxxxxxx" data-api="https://tt.acme.com" src="https://tt.acme.com/t.js"></script>| Reason | Effect |
|---|---|
| Ad blockers | Filter lists block known analytics hostnames, not tt.acme.com. Blocked traffic is missing traffic, and it skews by audience. |
| Content-Security-Policy | script-src 'self' and connect-src 'self' need no exception. |
| Latency | One fewer DNS lookup and TLS handshake on the critical path. |
What has to be proxied
Section titled “What has to be proxied”| Path | Method | Notes |
|---|---|---|
/t.js |
GET |
The tracker bundle. Cacheable — max-age=3600, stale-while-revalidate=86400, with an ETag. |
/c/:key |
GET |
The identity probe. Cacheable for 60 s. |
/e |
POST |
Every hit. Never cache. |
/api/send |
POST |
Only if you use the umami-compatible alias. |
Geo resolution also depends on it: a Cloudflare-fronted collector reads
request.cf, which describes the client Cloudflare terminated. Chaining
another CDN in front can turn that into the address of your proxy. See
Geo.
Recipes
Section titled “Recipes”The simplest option, and the one that needs no code. Create a hostname in your own zone and point it at the collector.
tt.acme.com. CNAME ingest.tracing.tools.Then:
<script defer data-site="pk_live_xxxxxxxx" data-api="https://tt.acme.com" src="https://tt.acme.com/t.js"></script>This is first-party by domain, which is what blocklists key on. It is not same-origin, so the requests still go through CORS — which the collector allows from everywhere.
import type { NextConfig } from "next";
const INGEST = "https://ingest.tracing.tools";
const config: NextConfig = { async rewrites() { return [ { source: "/tt/t.js", destination: `${INGEST}/t.js` }, { source: "/tt/c/:key", destination: `${INGEST}/c/:key` }, { source: "/tt/e", destination: `${INGEST}/e` }, ]; },};
export default config;<script defer data-site="pk_live_xxxxxxxx" data-api="/tt" src="/tt/t.js"></script>A relative data-api works: the tracker only concatenates, so "/tt" becomes
/tt/e and /tt/c/<key>, resolved against the current origin.
const INGEST = "https://ingest.tracing.tools";const ALLOWED = new Set(["/t.js", "/e", "/api/send"]);
export default { async fetch(request) { const url = new URL(request.url); const path = url.pathname.replace(/^\/tt/, "") || "/";
if (!ALLOWED.has(path) && !path.startsWith("/c/")) { return new Response("Not found", { status: 404 }); }
return fetch(`${INGEST}${path}${url.search}`, request); },};Cloudflare sets cf-connecting-ip on the outgoing subrequest, so the client
address survives.
location /tt/ { proxy_pass https://ingest.tracing.tools/; proxy_set_header Host ingest.tracing.tools; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; proxy_ssl_server_name on;
# /t.js is cacheable, /e is not proxy_cache_bypass $http_upgrade; proxy_buffering off;}<script defer data-site="pk_live_xxxxxxxx" data-api="/tt" src="/tt/t.js"></script>acme.com { handle_path /tt/* { reverse_proxy https://ingest.tracing.tools { header_up Host ingest.tracing.tools header_up X-Real-IP {remote_host} } }}Caddy sets X-Forwarded-For on a reverse_proxy by default.
Content-Security-Policy after proxying
Section titled “Content-Security-Policy after proxying”With a same-origin path proxy, both directives collapse to 'self':
script-src 'self';connect-src 'self';With a CNAME subdomain you still name the host, but it is yours:
script-src 'self' https://tt.acme.com;connect-src 'self' https://tt.acme.com;Verifying
Section titled “Verifying”curl -I https://tt.acme.com/t.js→200,content-type: application/javascript.curl https://tt.acme.com/c/pk_live_xxxxxxxx→{"m":"hash"}.- Load a page and confirm a
202fromPOST /tt/e. - Open Sessions and check that visits are not all coming from the same city — if they are, the client IP is not surviving the proxy.