PilotFox

Install the tracking script

Add the PilotFox script tag to your site, choose what it records, track single-page apps, allow it in a Content Security Policy and verify the installation.

The script tag

Put this tag in the <head> of every page, with your site's id in place of SITE_ID. The site's settings show the tag with the id filled in (Site settings → Tracking code).

<script defer src="https://pilotfox.app/api/s.js" data-site="SITE_ID"></script>
  • defer lets the browser download the script early and run it once the page has been parsed.
  • Keep it a classic <script src>, not type="module": the script finds its own tag through document.currentScript.
  • If a page optimizer strips data-* attributes, put the id in the URL instead: https://pilotfox.app/api/s.js?site=SITE_ID.
  • Load it at the start of the page, not later (for example after a click or a route change). A late script records the first pageview late and for the address of that moment, and misses errors thrown before it ran.

The script is small (under 10 KB compressed) and sets no cookies. It loads the site's settings from PilotFox, then sends each pageview and event as one request.

Install with an AI assistant

Site settings → Tracking code → Install with an AI assistant (and the last step of adding a site) has a Copy AI prompt button. Paste the prompt into Claude Code, Cursor or another coding assistant working in your site's codebase: it describes the tag with your site's id, single-page apps, identify for signed-in users and the checkout event that links payments to visits for each payment provider. It contains no keys or secrets.

What it records

These options are switches in Site settings → Tracking code. Browsers pick up a change within a minute.

OptionDefaultWhat it records
Initial pageviewOnA pageview when the script loads.
Single-page app navigationOnA pageview on each History API route change (see below).
Hash routingOff#/path and #!/path changes as pageviews of that path.
Query stringsOnThe page's query string with each pageview (what is stored of it is a privacy setting).
Outbound linksOnClicks on links to other sites, with the link text (up to 200 characters).
Button clicksOffClicks on buttons, submit inputs and role="button" elements, with their text.
Form submissionsOffThe form's id, name, action, method and number of fields, never the values.
Copy eventsOffThat text was copied, from which kind of element and how much, never the text.
Input changesOffThe name and type of a changed field, never the value.
JavaScript errorsOffUncaught errors and unhandled promise rejections.
Web vitalsOffLCP, CLS, INP, FCP and TTFB, once per pageview.
Payment links and return pagesOnA checkout event with a fresh reference when a link to a Stripe, Dodo Payments or Polar payment page is clicked (the reference is added to the link), and with the payment's id on the page the provider sends the buyer back to. See Revenue.

Session replay is a separate setting (Site settings → Session replay); see Privacy and data.

Single-page apps

With Single-page app navigation on, the script counts a pageview whenever the address changes through history.pushState, history.replaceState or the back and forward buttons. Changes within 300 ms are counted once, and a change to the same URL is not counted again. Set data-debounce to change the delay (in milliseconds).

To send pageviews yourself, turn Single-page app navigation off and call pilotfox.pageview() after each route change (or pilotfox.pageview(url) with the URL to record).

Script attributes

AttributeMeaning
data-siteYour site's id. Required (or ?site= on the script URL).
data-namespaceThe name of the script's global object. Default pilotfox.
data-debounceDelay in milliseconds before a route change counts as a pageview. Default 300.
data-skipA JSON array of path patterns that are never recorded, for example ["/admin/**"].
data-maskA JSON array of path patterns that hide part of a path, for example ["/orders/*"] records /orders/123 as /orders/*.

In patterns, * matches one path segment and ** any number of segments, and a matching path is recorded as the pattern itself. A pattern that starts with re: is a regular expression; in data-mask, each match is replaced with *.

Content Security Policy

If your site sends a Content Security Policy, allow PilotFox in two directives:

  • script-src: https://pilotfox.app (the script, and the replay recorder when session replay is on);
  • connect-src: https://pilotfox.app (settings, events, identifies and recordings).

Serve the script from your own domain

Some ad blockers block analytics scripts, and the requests they send, when they come from another domain. You can route them through your own site instead: your server forwards a path of your site, such as /_pf/, to PilotFox, and the page loads the script from that path. The script sends everything to the directory it was loaded from, so /_pf/s.js gets its settings from /_pf/c/… and sends its events to /_pf/e.

PilotFox counts visitors by IP address (see Privacy and data). Through a proxy every request comes from your server, so your server passes each visitor's address in the X-PilotFox-IP header, and a secret in X-PilotFox-Proxy-Secret proves that the header comes from your server. Without a valid secret the address header is ignored, so nobody else can choose the address a visit is counted from.

  1. In Site settings → Tracking code → Serve from your own domain, choose the path and click Create secret. Copy the secret: it is shown once. Store it on your server, for example as the environment variable PILOTFOX_PROXY_SECRET. Never put it in code that runs in the browser.
  2. Configure your server with one of the examples below (the settings page fills in your path and the PilotFox address).
  3. Replace the script tag with one that loads from your path: <script defer src="/_pf/s.js" data-site="SITE_ID"></script>.

Each example forwards /_pf/* to https://pilotfox.app/api/*, sets X-PilotFox-IP from the connection your server received (never from a header your visitors can send), adds the secret, and drops your visitors' cookies: they belong to your site, and PilotFox reads none.

Next.js

// proxy.ts at the root of your app (Next.js 16).
// On Next.js 15 or older: name the file middleware.ts and the function middleware.
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const { pathname, search } = request.nextUrl;
  const url = new URL(pathname.replace(/^\/_pf/, "/api") + search, "https://pilotfox.app");
  const headers = new Headers(request.headers);
  headers.delete("cookie"); // your visitors' cookies stay on your site
  headers.set("x-pilotfox-proxy-secret", process.env.PILOTFOX_PROXY_SECRET ?? "");
  // The visitor's address: x-forwarded-for on Vercel, which sets it. Self-hosted: the
  // header your own front proxy sets, never one your visitors can send.
  headers.set("x-pilotfox-ip", request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "");
  return NextResponse.rewrite(url, { request: { headers } });
}

export const config = { matcher: "/_pf/:path*" };

Vercel sets x-forwarded-for to the visitor's address and overwrites any value the visitor sends. Behind your own load balancer, use the header it sets. Plain rewrites in next.config.js can forward the path but cannot add headers, so every visitor would have your server's address.

Caddy

# Inside your site's block in the Caddyfile.
# PILOTFOX_PROXY_SECRET is read from Caddy's environment.
handle_path /_pf/* {
	rewrite * /api{path}
	reverse_proxy https://pilotfox.app {
		header_up Host {upstream_hostport}
		header_up X-PilotFox-IP {client_ip}
		header_up X-PilotFox-Proxy-Secret {$PILOTFOX_PROXY_SECRET}
		header_up -Cookie
	}
}

{client_ip} is the address of the connection, or the one your trusted proxies report if you set trusted_proxies.

nginx

# Inside your site's server block. Paste the secret where it says so
# (nginx reads no environment variables here). Behind a load balancer,
# set $remote_addr from it with the realip module first.
location /_pf/ {
    proxy_pass https://pilotfox.app/api/;
    proxy_ssl_server_name on;      # send the name pilotfox.app for TLS (off by default)
    proxy_set_header Host pilotfox.app;
    proxy_set_header X-PilotFox-IP $remote_addr;
    proxy_set_header X-PilotFox-Proxy-Secret "PASTE_THE_SECRET_HERE";
    proxy_set_header Cookie "";    # your visitors' cookies stay on your site
}

Cloudflare Worker

// A Worker on the route example.com/_pf/* of your zone. Add the secret with
// `wrangler secret put PILOTFOX_PROXY_SECRET` (or the dashboard's Variables and Secrets).
export default {
  async fetch(request, env) {
    const { pathname, search } = new URL(request.url);
    const url = new URL(pathname.replace(/^\/_pf/, "/api") + search, "https://pilotfox.app");
    const headers = new Headers(request.headers);
    headers.delete("cookie"); // your visitors' cookies stay on your site
    headers.set("x-pilotfox-ip", request.headers.get("cf-connecting-ip") ?? "");
    headers.set("x-pilotfox-proxy-secret", env.PILOTFOX_PROXY_SECRET);
    return fetch(url, { method: request.method, headers, body: request.body });
  },
};

Notes

  • A request whose secret is wrong, was replaced or removed, or whose X-PilotFox-IP is missing or not a single IP address is refused with 400 Invalid proxy secret; a secret of another site with 400 The proxy secret is for another site. Nothing from it is recorded.
  • Replace in the settings stops the old secret at once: update your server right after. Remove turns the proxy off: requests that still carry the secret are refused.
  • If the secret leaks, replace it. It lets its holder choose the IP address of events for that one site, nothing else: it gives no access to your data.
  • Visitors are located from their own address. Cloudflare's location headers are not used for requests that come through a proxy.
  • Your Content Security Policy then needs only your own origin ('self') for the script and its requests.

Verify the installation

Verify installation (in Site settings → Tracking code, and at the end of adding a site) loads your site's home page, over HTTPS and then HTTP if HTTPS does not answer, and looks for the PilotFox tag with your site's id. It reports one of: installed, not installed, a tag with another site's id, or that the page could not be loaded.

Then open the site's dashboard and visit your site in another tab: the live view shows the visit.

Keep traffic out

In Site settings:

  • Allowed hostnames (Privacy & identity): events from any other hostname are dropped, for example from a staging copy that still carries the tag. *.example.com matches every subdomain, at any depth (www.example.com, a.b.example.com), but not example.com itself: list that separately.
  • Exclusions: drop traffic by IP address or range, country, path, user agent or network (ASN), for example your office or uptime monitors.
  • Bots: bot traffic is dropped by default and shown in the Bots report instead.
  • Your own browser: open any page of your site with ?pilotfox_ignore=true added to the address (for example https://example.com/?pilotfox_ignore=true), or run pilotfox.optOut() in the browser console there. That browser is no longer counted on your site; ?pilotfox_ignore=false undoes ?pilotfox_ignore=true (never optOut(), which may be a visitor's own choice), and pilotfox.optIn() undoes either. It is remembered in the browser's localStorage, so clearing site data undoes it too.
  • Tagged links: Site settings → Link builder adds campaign (utm_*) or affiliate (?ref=) parameters to a link of your site, so visits from posts on X, LinkedIn, Medium or a newsletter, which often arrive without a referrer, are reported under their campaign.

Last updated · Markdown version