Pirsch: Cookieless Server-Side Web Analytics | OpsBlu Docs

Pirsch: Cookieless Server-Side Web Analytics

Implement Pirsch privacy-first analytics with cookieless fingerprint hashing, server-side Go/Node SDKs, custom events, and the REST API for...

How Pirsch Works

Pirsch is a cookieless web analytics platform written in Go that tracks visitors without storing any persistent identifiers. Instead of cookies or localStorage tokens, Pirsch generates a daily fingerprint by hashing the visitor's IP address, User-Agent string, and a date-based salt on the server side. The hash rotates every 24 hours at midnight (UTC), making it impossible to track a visitor across days. The raw IP address is never stored.

The data flow works like this: a visitor loads a page, the pirsch.js script (under 1 KB gzipped) sends a POST request to api.pirsch.io/api/v1/hit containing the page URL, referrer, screen resolution, and UTM parameters. The Pirsch server receives the request, extracts the visitor's IP and User-Agent from the HTTP headers, generates the daily fingerprint hash, and increments counters in PostgreSQL. Because all identification happens server-side, there is nothing stored on the client -- no cookies, no localStorage, no fingerprinting JavaScript.

This architecture means Pirsch does not require cookie consent banners under GDPR, CCPA, or the ePrivacy Directive. The European Data Protection Board considers hashed, daily-rotating identifiers that cannot be linked back to individuals as non-personal data when the raw IP is discarded immediately after hashing.

Pirsch is available as a managed cloud service (hosted in Germany on Hetzner) or as an open-source self-hosted binary backed by PostgreSQL.

Installing Pirsch

Client-Side Script

Add the tracking script to your <head>. The data-code value is the identification code from your Pirsch dashboard under Settings > Integration.

Pageview-only tracking:

<script defer src="https://api.pirsch.io/pirsch.js"
  id="pirschjs"
  data-code="YOUR_IDENTIFICATION_CODE"></script>

Pageview + event tracking (extended script):

<script defer src="https://api.pirsch.io/pirsch-extended.js"
  id="pirschextendedjs"
  data-code="YOUR_IDENTIFICATION_CODE"></script>

The extended script adds the global pirsch() function for custom event tracking. It is approximately 1.5 KB gzipped.

Script Configuration Attributes

<script defer src="https://api.pirsch.io/pirsch-extended.js"
  id="pirschextendedjs"
  data-code="YOUR_CODE"
  data-dev="localhost,staging.example.com"
  data-disable-outbound-links="true"
  data-hit-endpoint="https://your-proxy.example.com/api/v1/hit"
  data-event-endpoint="https://your-proxy.example.com/api/v1/event"></script>
Attribute Purpose Default
data-code Identification code (required) --
data-dev Comma-separated hostnames to exclude from tracking --
data-disable-outbound-links Stop automatic outbound link click tracking false
data-hit-endpoint Override the pageview API endpoint (for proxying) https://api.pirsch.io/api/v1/hit
data-event-endpoint Override the event API endpoint (for proxying) https://api.pirsch.io/api/v1/event

Server-Side Tracking (Go SDK)

Server-side tracking bypasses ad blockers entirely because the hit is sent from your backend, not the visitor's browser. Install the Go SDK:

go get github.com/pirsch-analytics/pirsch-go-sdk/v2
package main

import (
    "net/http"
    pirsch "github.com/pirsch-analytics/pirsch-go-sdk/v2"
)

var client = pirsch.NewClient("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET", nil)

func handler(w http.ResponseWriter, r *http.Request) {
    // Forward the hit to Pirsch -- pass the original request
    // so Pirsch can extract IP and User-Agent from headers
    if err := client.Hit(r); err != nil {
        log.Printf("pirsch hit error: %v", err)
    }

    w.Write([]byte("OK"))
}

The SDK authenticates using OAuth2 client credentials. Create a client ID and secret in Pirsch dashboard under Settings > Integration > Access Clients.

Server-Side Tracking (Node.js)

npm install pirsch-sdk
const { Pirsch } = require('pirsch-sdk');

const client = new Pirsch({
  clientId: 'YOUR_CLIENT_ID',
  clientSecret: 'YOUR_CLIENT_SECRET'
});

// Express middleware
app.use(async (req, res, next) => {
  try {
    await client.hit({
      url: `https://example.com${req.path}`,
      ip: req.ip,
      user_agent: req.headers['user-agent'],
      accept_language: req.headers['accept-language'],
      referrer: req.headers['referer'] || ''
    });
  } catch (err) {
    console.error('pirsch hit failed:', err.message);
  }
  next();
});

Server-Side Tracking (HTTP API)

If no SDK exists for your language, send hits directly via the REST API:

# Step 1: Get an access token
curl -X POST https://api.pirsch.io/api/v1/token \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "YOUR_CLIENT_ID",
    "client_secret": "YOUR_CLIENT_SECRET"
  }'
# Returns: {"access_token": "eyJhb..."}

# Step 2: Send a pageview hit
curl -X POST https://api.pirsch.io/api/v1/hit \
  -H "Authorization: Bearer eyJhb..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/pricing",
    "ip": "203.0.113.42",
    "user_agent": "Mozilla/5.0 ...",
    "accept_language": "en-US,en;q=0.9",
    "referrer": "https://google.com"
  }'

The access token expires after 30 minutes. Cache it and refresh before expiry.

Events and Goals

Events require the extended script (pirsch-extended.js) on the client side, or the events API endpoint on the server side.

Client-Side Events

// Basic event
pirsch('Signup Button Click');

// Event with metadata (key-value pairs, values must be strings)
pirsch('Purchase Complete', {
  meta: {
    plan: 'Pro Annual',
    value: '149.99',
    currency: 'USD',
    payment_method: 'stripe'
  }
});

// Track duration (pass duration in seconds)
pirsch('Video Watch', {
  duration: 245,
  meta: {
    video_id: 'onboarding-tour',
    completed: 'true'
  }
});

Metadata values are always strings. Pirsch stores them as-is and lets you filter by metadata key/value pairs in the dashboard.

Server-Side Events (Go)

err := client.Event(r, "Purchase Complete", 0, map[string]string{
    "plan":     "Pro Annual",
    "value":    "149.99",
    "currency": "USD",
})

Server-Side Events (HTTP API)

curl -X POST https://api.pirsch.io/api/v1/event \
  -H "Authorization: Bearer eyJhb..." \
  -H "Content-Type: application/json" \
  -d '{
    "event_name": "Purchase Complete",
    "url": "https://example.com/checkout/success",
    "ip": "203.0.113.42",
    "user_agent": "Mozilla/5.0 ...",
    "event_meta": {
      "plan": "Pro Annual",
      "value": "149.99"
    }
  }'

Goals

Goals in Pirsch are pattern-based conversion tracking rules defined in the dashboard. You can create goals based on:

  • Page visits: Visitor reaches /checkout/success
  • Custom events: The event Purchase Complete fires
  • Event metadata: The event Signup fires with plan equal to Enterprise
  • Path patterns: Any URL matching /blog/* is visited

Goals calculate conversion rates automatically by comparing the number of goal completions to total visitors in the selected date range. Define goals under Dashboard > Goals.

Dashboard Metrics

Pirsch collects the following metrics without cookies:

Metric Source Notes
Unique visitors Daily fingerprint hash (IP + UA + salt) Resets at midnight UTC; same visitor on two days counts as two
Page views One per pirsch.js POST or server-side /hit call SPA route changes need manual pirsch.hit() calls
Sessions Inferred from sequential hits within 15-minute windows No cookie means sessions reset if visitor closes and reopens the browser
Bounce rate Single-pageview sessions / total sessions
Session duration Time between first and last hit in a session
Entry pages First page URL in each session
Exit pages Last page URL in each session
Referrers Referer HTTP header or ref/utm_source query parameter
UTM parameters utm_source, utm_medium, utm_campaign, utm_content, utm_term Parsed from the page URL
Country / Region GeoIP lookup from visitor IP (MaxMind) IP discarded after geolocation
Browser / OS Parsed from User-Agent string
Screen resolution Reported by pirsch.js via screen.width x screen.height Not available with server-side tracking
Device type Inferred from User-Agent (desktop, mobile, tablet)

API Reference

Pirsch exposes a REST API for reading analytics data programmatically. Authenticate with the same OAuth2 client credentials used for server-side tracking.

Get Visitors Over Time

curl "https://api.pirsch.io/api/v1/statistics/visitor?from=2026-03-01&to=2026-03-05&id=YOUR_DOMAIN_ID" \
  -H "Authorization: Bearer eyJhb..."

Returns daily visitor and pageview counts:

[
  {"day": "2026-03-01", "visitors": 1243, "views": 3891, "sessions": 1587, "bounces": 412},
  {"day": "2026-03-02", "visitors": 1102, "views": 3456, "sessions": 1401, "bounces": 389}
]

Get Top Pages

curl "https://api.pirsch.io/api/v1/statistics/page?from=2026-03-01&to=2026-03-05&id=YOUR_DOMAIN_ID" \
  -H "Authorization: Bearer eyJhb..."

Get Referrers

curl "https://api.pirsch.io/api/v1/statistics/referrer?from=2026-03-01&to=2026-03-05&id=YOUR_DOMAIN_ID" \
  -H "Authorization: Bearer eyJhb..."

Get Event Data

curl "https://api.pirsch.io/api/v1/statistics/event?from=2026-03-01&to=2026-03-05&id=YOUR_DOMAIN_ID&event_name=Purchase%20Complete" \
  -H "Authorization: Bearer eyJhb..."

Filtering

All statistics endpoints accept these query parameters:

Parameter Type Description
from YYYY-MM-DD Start date (required)
to YYYY-MM-DD End date (required)
id string Domain ID from dashboard (required)
path string Filter by URL path
country string ISO 3166-1 alpha-2 country code
referrer string Filter by referrer domain
os string Filter by operating system
browser string Filter by browser name
language string Filter by visitor language
event_name string Filter by event name (events endpoint only)
event_meta_key string Filter by metadata key (events endpoint only)

SPA (Single-Page Application) Support

Pirsch does not automatically detect client-side route changes. For React, Vue, Next.js, or any SPA, you need to manually trigger pageview hits on navigation.

// React with react-router
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

function usePirschPageview() {
  const location = useLocation();

  useEffect(() => {
    if (typeof pirsch !== 'undefined' && pirsch.hit) {
      pirsch.hit();
    }
  }, [location.pathname]);
}
// Next.js (pages router)
import { useRouter } from 'next/router';
import { useEffect } from 'react';

export default function App({ Component, pageProps }) {
  const router = useRouter();

  useEffect(() => {
    const handleRouteChange = () => {
      if (typeof pirsch !== 'undefined' && pirsch.hit) {
        pirsch.hit();
      }
    };
    router.events.on('routeChangeComplete', handleRouteChange);
    return () => router.events.off('routeChangeComplete', handleRouteChange);
  }, [router.events]);

  return <Component {...pageProps} />;
}

Self-Hosting

Pirsch is open source and can be self-hosted with Docker:

docker run -d \
  --name pirsch \
  -p 8080:8080 \
  -e PIRSCH_DB_HOST=postgres \
  -e PIRSCH_DB_PORT=5432 \
  -e PIRSCH_DB_USER=pirsch \
  -e PIRSCH_DB_PASSWORD=secret \
  -e PIRSCH_DB_NAME=pirsch \
  -e PIRSCH_DB_SSL_MODE=disable \
  -e PIRSCH_SECRET=your-random-secret-key \
  pirsch/server:latest

Requirements: PostgreSQL 12+ with at least 1 GB RAM for the database. The Pirsch server binary uses approximately 50 MB of RAM. For sites under 1 million pageviews/month, a single 1-vCPU server handles both the application and database.

Proxying to Bypass Ad Blockers

Ad blockers that block api.pirsch.io will prevent client-side tracking. Proxy the Pirsch endpoints through your own domain to avoid this:

Nginx:

location /p/hit {
    proxy_pass https://api.pirsch.io/api/v1/hit;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Real-IP $remote_addr;
}

location /p/event {
    proxy_pass https://api.pirsch.io/api/v1/event;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Real-IP $remote_addr;
}

location /p/pirsch.js {
    proxy_pass https://api.pirsch.io/pirsch-extended.js;
}

Then update your script tag to use the proxied paths:

<script defer src="/p/pirsch.js"
  id="pirschextendedjs"
  data-code="YOUR_CODE"
  data-hit-endpoint="/p/hit"
  data-event-endpoint="/p/event"></script>

The X-Forwarded-For header is critical -- without it, Pirsch will see your server's IP for every visitor instead of the actual visitor IP.

Common Errors

Error Cause Fix
No data in dashboard after installing script data-code does not match the identification code in Pirsch settings, or ad blocker is blocking api.pirsch.io Verify the code matches Settings > Integration; test in an incognito window with extensions disabled
All visitors show the same country Proxy or CDN stripping the real visitor IP; X-Forwarded-For not set Configure your reverse proxy to pass X-Forwarded-For with the client IP; for Cloudflare, Pirsch reads CF-Connecting-IP automatically
Server-side SDK returns 401 Unauthorized Access token expired (tokens last 30 minutes) or client credentials are wrong Refresh the token before expiry; verify client ID and secret in Settings > Integration > Access Clients
Events not appearing in dashboard Using pirsch.js instead of pirsch-extended.js, or event metadata values are not strings Switch to the extended script; convert all metadata values to strings ('149.99' not 149.99)
Duplicate pageviews on SPA pirsch.js fires on initial load and again on route change without deduplication Call pirsch.hit() only on routeChangeComplete, not on initial mount (the script already tracks the first load)
Session count much higher than unique visitors Cookieless tracking cannot link sessions across browser restarts; each new browser session generates a new session This is expected behavior; Pirsch sessions are more conservative than cookie-based platforms
Bounce rate unusually high SPA not sending subsequent hits on route changes, so every visit appears as a single-page session Implement manual pirsch.hit() calls on client-side navigation
Self-hosted instance returning 500 errors PostgreSQL connection failed or migrations not applied Check PIRSCH_DB_* environment variables; run pirsch migrate to apply database schema
Real-time counter always shows 0 Using server-side tracking without the pirsch.js script; real-time requires the client-side script Add the client-side script alongside server-side tracking, or accept that server-side-only tracking does not support real-time
Data discrepancy vs. server access logs Pirsch deduplicates by daily fingerprint; access logs count every request including bots Pirsch filters known bots via User-Agent matching; the lower Pirsch number is typically more accurate for human visitors