Site Tracking
Site Tracking for any backend
Add Site Tracking to any server that can make an HTTP request, from Python and Ruby to Go and PHP, with the rules every install must follow.
Site Tracking needs one thing from your server: after it answers a page request, it sends a GET request to Ranqo describing that page view. Any language or framework that can make an outbound HTTP request can do it. If you use Next.js, Express or a Cloudflare Worker, their guides have ready-made code.
The contract
Every install, in any language, follows five rules:
- Fire after responding. Send the report once your response is on its way, never before.
- Never block. Send it in the background (a thread, a goroutine, a queued job) and do not wait for the answer. Use a short timeout.
- Ignore the result, and never retry. Ranqo answers
200with{"ok": true}or{"ok": false}, and a failure on its side can return an error status instead. None of these needs handling. Every accepted report is recorded as a new visit, so retrying one counts the page view twice. - Report page views only. Skip the requests the install code for every stack skips (below).
- Send an address the visitor cannot choose. Never the first entry of
X-Forwarded-For; see The visitor's address.
What to send
One GET request to the intake endpoint with six query parameters, URL-encoded:
The Intake API page covers the endpoint in full, including its limits.
The visitor's address
Ranqo checks each crawler visit against the IP ranges the crawler's operator publishes, so ip has to be an address the visitor cannot choose. The first entry of X-Forwarded-For is not one: anyone can send the header with Googlebot's address in it, and a proxy adds its own entry to the right of whatever arrived.
Start from the address of the connection itself (REMOTE_ADDR in most frameworks). When that address is one of your own proxies, take the next X-Forwarded-For entry from the right instead, and keep going while the entry is also one of your proxies. The first address that is not yours is the visitor, as your outermost proxy saw them.
- Name your proxies by address, not by how many there are. A count trusts whoever connects, so a visitor who reaches your server around its proxies can write as many entries as you count.
127.0.0.1and::1cover nginx or Caddy on the same machine; add your load balancer's or host's private network, and the published ranges of a CDN that connects from public addresses. - Each proxy must add the address it received to the header. In nginx,
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;. - A header your platform writes, such as Cloudflare's
CF-Connecting-IP, can replace the walk, but only when every request reaches your server through that platform. A visitor who reaches the server directly can send the header too.
To see what arrives, log REMOTE_ADDR and X-Forwarded-For for a request with a made-up entry, such as curl -H "X-Forwarded-For: 192.0.2.1" https://www.example.com/. REMOTE_ADDR is your nearest proxy when one sits in front, and the entries to the right of your own public address are any further proxies. If X-Forwarded-For arrives as just 192.0.2.1, nothing in front of your server adds to it, and REMOTE_ADDR is the visitor.
What to skip
- Any method other than
GET. - Prefetch and framework data requests: a
Sec-Fetch-Destheader other thandocument, aNext-Router-Prefetch,Next-Router-Segment-PrefetchorRSC: 1header, or aSec-PurposeorPurposeheader sayingprefetch. - Paths under
/api/. - Static files: images, fonts, scripts, stylesheets, source maps, and
.txt,.xmland.jsonfiles, except the ones below. - Hosts you do not want counted, such as your signed-in product if the same server runs it.
Always report robots.txt, llms.txt, llms-full.txt, sitemap.xml and sitemap-*.xml, ai.txt and anything under /.well-known/. They are the first files AI crawlers ask for.
From the command line
The same request as a shell command, run in the background. Replace the variables with your framework's values.
# After your handler returns, fire this in the background — never block the user:
# CLIENT_IP is the visitor's address as your servers saw it: the connection's
# own address or, when that is one of your own proxies, the X-Forwarded-For
# entry your proxies added, read from the right. Never the first entry, which
# the visitor can write.
curl -sG "https://app.ranqo.ai/api/v1/intake/pageview" \
--data-urlencode "url=$REQUEST_URL" \
--data-urlencode "userAgent=$REQUEST_USER_AGENT" \
--data-urlencode "ref=$REQUEST_REFERER" \
--data-urlencode "ip=$CLIENT_IP" \
--data-urlencode "country=$VISITOR_COUNTRY" \
--data-urlencode "websiteKey=$RANQO_SITE_KEY" \
>/dev/null 2>&1 &Python
A Django middleware that applies every rule above and sends the report from a background thread with a half-second timeout. The same shape works in Flask, FastAPI and other frameworks: wrap the response, then fire the request in the background.
# ranqo_intake.py — Django middleware. Adapt to Flask, FastAPI, etc.
import ipaddress, os, re, threading
from urllib.parse import urlencode
import urllib.request
SKIP_EXT = re.compile(r"\.(png|jpg|jpeg|svg|gif|webp|ico|css|js|mjs|map|woff2?|ttf|otf|eot|txt|xml|json)$", re.I)
TRACK_AI = re.compile(r"^/(robots\.txt|llms(-full)?\.txt|sitemap(-.*)?\.xml|ai\.txt|\.well-known/.*)$")
INTAKE = "https://app.ranqo.ai/api/v1/intake/pageview"
# OPTIONAL: skip self-tracking on subdomains you don't want counted as
# "marketing site visitors" (e.g. your authenticated dashboard).
SKIP_HOSTS = set([
# "app.yoursite.com",
# "admin.yoursite.com",
])
# The header your CDN writes with the visitor's country, as Django names it:
# "HTTP_CF_IPCOUNTRY" behind Cloudflare, "HTTP_CLOUDFRONT_VIEWER_COUNTRY"
# behind CloudFront. On Vercel this is ignored: Vercel writes its own. Left
# empty, no country is sent. Name only a header your CDN sets on every request:
# a visitor can send any header themselves.
COUNTRY_HEADER = ""
# Your own proxies, by address. When a request arrives from one of them, the
# visitor's address is read from X-Forwarded-For instead, from the right, past
# every entry that is also one of them; a visitor can write anything to the
# left. The default trusts this machine only (nginx or Caddy in front of the
# app). Behind a load balancer, your host's router or another container, add
# its network, such as "10.0.0.0/8".
TRUSTED_PROXIES = ["127.0.0.1", "::1"]
def _is_proxy(address):
try:
ip = ipaddress.ip_address(address)
except ValueError:
return False
ip = getattr(ip, "ipv4_mapped", None) or ip
return any(ip in ipaddress.ip_network(net, strict=False) for net in TRUSTED_PROXIES)
def _visitor_ip(request):
address = request.META.get("REMOTE_ADDR", "")
hops = [h.strip() for h in request.META.get("HTTP_X_FORWARDED_FOR", "").split(",") if h.strip()]
while hops and _is_proxy(address):
address = hops.pop()
return address
def _visitor_country(request):
header = "HTTP_X_VERCEL_IP_COUNTRY" if os.environ.get("VERCEL") else COUNTRY_HEADER
return request.META.get(header, "") if header else ""
def _fire(params):
try:
req = urllib.request.Request(INTAKE + "?" + urlencode(params))
urllib.request.urlopen(req, timeout=0.5).read()
except Exception:
pass # fire-and-forget
class RanqoIntakeMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Only GET is a page view. Skip non-GET and any prefetch / RSC
# request (Next.js apps behind a Python proxy would otherwise inflate).
if request.method != "GET":
return response
sec_fetch_dest = request.META.get("HTTP_SEC_FETCH_DEST", "")
if sec_fetch_dest and sec_fetch_dest != "document":
return response
if request.META.get("HTTP_NEXT_ROUTER_PREFETCH"):
return response
if request.META.get("HTTP_NEXT_ROUTER_SEGMENT_PREFETCH"):
return response
if request.META.get("HTTP_RSC") == "1":
return response
sec_purpose = request.META.get("HTTP_SEC_PURPOSE", "")
if sec_purpose.startswith("prefetch"):
return response
if request.META.get("HTTP_PURPOSE") == "prefetch":
return response
path = request.path
host = request.get_host()
if host in SKIP_HOSTS:
return response
if not path.startswith("/api/") and (TRACK_AI.match(path) or not SKIP_EXT.search(path)):
params = {
"url": request.build_absolute_uri(),
"userAgent": request.META.get("HTTP_USER_AGENT", ""),
"ref": request.META.get("HTTP_REFERER", ""),
"ip": _visitor_ip(request),
"country": _visitor_country(request),
"websiteKey": os.environ.get("RANQO_SITE_KEY", ""),
}
threading.Thread(target=_fire, args=(params,), daemon=True).start()
return responseAdd the class to your MIDDLEWARE setting by its module path (for example ranqo_intake.RanqoIntakeMiddleware) and set RANQO_SITE_KEY in the environment. The example reports REMOTE_ADDR, reading X-Forwarded-For only past the proxies listed in TRUSTED_PROXIES, as The visitor's address describes. The default list is this machine only, which suits nginx or Caddy in front of the app. Behind a load balancer, your host's router or another container, add its network, such as "10.0.0.0/8".
On Vercel, the example sends the country in Vercel's own header. Elsewhere it sends none until you set COUNTRY_HEADER to your CDN's header as Django names it, such as HTTP_CF_IPCOUNTRY behind Cloudflare.
Other languages
Translate the Python middleware directly: the same checks, the same six parameters, and a background request with a short timeout.
- Ruby: a Rack middleware that calls the app, then sends the request from a thread.
- Go: an
http.Handlerwrapper that calls the next handler, then starts a goroutine for the request. - PHP: send the request after the response has been flushed to the visitor, so the page is not held up.
Whatever the language, never let an error from the report reach the visitor.
Check it
Run the check in Verify your install, then load a page on your site and watch the install page in the dashboard.