Search by

oliweb / statamic-privacy-analytics

Self-hosted, privacy-first analytics for Statamic. No third-party data transfer by default — MaxMind geolocation, configurable retention, granular CP permissions.

Maintainers

Package info

github.com/oliweb-ch/statamic-privacy-analytics

Type:statamic-addon

pkg:composer/oliweb/statamic-privacy-analytics

Transparency log

Fund package maintenance!

Other

Statistics

Installs: 269

Dependents: 0

Suggesters: 0

Stars: 1


README

PHP Require Statamic 6 Tests

A self-hosted, privacy-first analytics addon for Statamic. No Google. No third-party scripts. No analytics-specific cookies by default (the addon uses the host application's Laravel session, which may itself rely on a session cookie). Your data stays on your server.

Originally forked from mohammedshuaau/enhanced-analytics. Since then, the project has diverged substantially: a privacy-first geolocation architecture (MaxMind local by default), a full data retention and anonymisation system, granular CP permissions, optional asynchronous tracking, and an extensive automated test suite — none of which existed in the original.

Why this addon?

  • Zero external tracking dependencies — no Google Analytics, no Matomo cloud, no Plausible cloud
  • Direct DB writes — every page view is recorded instantly, no processing queue needed for real-time data
  • Privacy-conscious by design — configurable data retention, anonymisation, purge, consent banner (optional), and disableable geolocation; provides the mechanisms for GDPR/nLPD-conscious deployments without constituting a legal guarantee in itself
  • Geolocation — IP → country/city via ip-api.com (HTTP, free tier) or locally with MaxMind GeoLite2 (no external calls)

Features

Dashboard

  • Date ranges : 24h, 7 days, 30 days, custom
  • Comparison with previous period (visits, unique visitors, bounce rate)
  • CSV export
  • Auto-refresh (configurable interval)
  • Dark mode support

Widgets

Widget Description
Overview Total visits, unique visitors, avg. time on site, bounce rate
Visit frequency New vs returning, pages/session, avg. session duration
Page views over time Line chart, total + unique views per day
Top countries Bar chart + table with % of total
Device types Doughnut chart (desktop / mobile / tablet)
Browser usage Doughnut chart
Traffic sources Direct / Search / Social / Referral + top referring domains
Platforms / OS Horizontal bar chart
Top cities Table with progress bars
Activity heatmap 7-day × 24-hour CSS grid, intensity-based coloring
Real-time visitors Active sessions/visitors in the last 5 / 15 / 30 min, auto-refresh every 30s
New vs returning trend Stacked area chart over the selected period
Session depth Page distribution per session (1 / 2-3 / 4-5 / 6-10 / 10+)
Page performance Top 10 pages: views, unique views, avg. time, bounce rate, exit rate
User flow Entry pages, most engaged pages, exit pages

CP Dashboard Widget

A compact widget is automatically injected into the Statamic Control Panel dashboard, displaying at-a-glance stats for the last 7 days:

  • Visits today
  • Total page views (7 days)
  • Unique visitors (7 days)

The widget links directly to the analytics dashboard. It is auto-injected if no analytics_overview entry is already present in config/statamic/cp.php. To control its position or width, add it explicitly:

// config/statamic/cp.php
'widgets' => [
    ['type' => 'analytics_overview', 'width' => 50],
    // ...
],

Privacy & tracking

A non-technical summary of what data is collected, retained, and who can access it is available in docs/data-processing-overview.md (French version) — useful to share directly with a client or their legal counsel.

  • Consent banner (disabled by default) with granular controls
  • Bot filtering
  • Configurable excluded paths and IPs
  • Optional authenticated user tracking — when enabled (default), the authenticated user ID is temporarily associated with analytics events and automatically set to NULL during the anonymisation process (privacy.ip_retention_days); this is not a permanent association
  • Geolocation optional per-visitor via consent settings
  • Three providers : ip-api (external HTTP), maxmind (local database, no external calls), disabled
  • Automatic IP retentionip_address, user_agent, and user_id are anonymised (set to NULL) after 90 days by default; configurable via ANALYTICS_IP_RETENTION_DAYS
  • HTML-only tracking — only responses with a 200 OK status and a text/html Content-Type are recorded. Automated scans (404s), static assets served by third-party addons (.js, .wasm, etc.), and API JSON responses are silently ignored without requiring a manual exclusion list.
  • Session-safe — the tracking middleware never invalidates or regenerates the session. It only adds its own keys (analytics_session_started, visitor_id, visited_pages, last_visit_date, last_visit_hour); all pre-existing session data (cart, auth, form state, etc.) is left intact.
  • Full static caching compatible — see Static caching (full strategy) below.

CP Permissions

Two granular permissions gate access to the analytics control panel routes. They are assignable to any Statamic role via CP → Users → Roles.

Permission Slug What it covers
View analytics dashboard analytics.view Dashboard, aggregated data, geolocation stats, real-time visitors
Export and manage analytics data analytics.manage CSV export (contains plain-text IPs), geolocation stats reset

Super administrators bypass all permission checks automatically — they always have full access regardless of role configuration.

Users who hold analytics.view but not analytics.manage will see the dashboard and all statistics. The Export CSV button and the geolocation stats reset button are hidden in the UI (client-side UX only — the routes themselves are protected server-side independently).

Requirements

  • PHP ≥ 8.3
  • Statamic ≥ 6.0
  • Any database engine supported by your Statamic/Laravel installation — the addon uses Laravel's database abstraction exclusively and does not require a specific engine. Tested in CI against: SQLite, MySQL 8.0, MariaDB 11.

Installation

composer require oliweb/statamic-privacy-analytics

Publish the configuration:

php artisan vendor:publish --tag=statamic-analytics-config

Run the migrations:

php artisan migrate

The addon starts tracking immediately. Access the dashboard via Control Panel → Tools → Analytics.

Configuration

config/statamic-analytics.php :

return [
    'cache' => [
        'driver' => env('STATAMIC_ANALYTICS_CACHE_DRIVER', 'file'),
        'file' => [
            'path' => storage_path('app/statamic-analytics'),
            'permissions' => [
                'file'      => 0644,
                'directory' => 0755,
            ],
        ],
    ],

    'geolocation' => [
        'provider'       => env('ANALYTICS_GEO_PROVIDER', 'maxmind'), // 'disabled' | 'ip-api' | 'maxmind'
        'cache_duration' => 1440, // minutes (24h)

        'ip_api' => [
            'rate_limit' => 45, // requests per minute (ip-api.com free tier)
        ],

        'maxmind' => [
            'database_path' => storage_path('app/geoip/GeoLite2-City.mmdb'),
            'account_id'    => env('MAXMIND_ACCOUNT_ID'),
            'license_key'   => env('MAXMIND_LICENSE_KEY'),
        ],
    ],

    'processing' => [
        'frequency'  => 15,   // minutes — aggregate recalculation frequency
        'chunk_size' => 1000,
    ],

    'dashboard' => [
        'refresh_interval' => 300, // seconds
    ],

    'tracking' => [
        'exclude_paths' => ['cp/*', 'api/*'],
        'exclude_ips'   => [],
        'exclude_bots'  => true,
        'track_authenticated_users' => true,
        'consent' => [
            'enabled' => false, // set to true to require visitor consent
            'banner'  => [
                'title'          => 'Privacy Notice',
                'description'    => 'We use analytics to understand how visitors use our site.',
                'accept_button'  => 'Accept',
                'decline_button' => 'Decline',
                'settings_button'=> 'Customize',
                'position'       => 'bottom', // bottom | top | center
            ],
        ],
    ],

    'privacy' => [
        'ip_retention_days' => env('ANALYTICS_IP_RETENTION_DAYS', 90), // null = unlimited (not recommended)
    ],

    'enable_debugging' => false,
];

Configuration notes

privacy.ip_retention_days — Number of days ip_address, user_agent, and user_id are kept before anonymisation. null disables automatic anonymisation — not recommended, and unlimited retention may be difficult to justify depending on the requirements applicable to your deployment. See IP retention.

enable_debugging — Enables detailed logging in the tracking middleware. Leave this false outside of development.

geolocation.provider backward compatibility — If the provider key is absent from the published config (generated before this version), the addon falls back to the legacy boolean key enabled: enabled=trueip-api, enabled=falsedisabled. Migrate explicitly to provider on your next vendor:publish --force.

Geolocation

Providers

Provider External call Data Requirements
ip-api Yes (ip-api.com) Country + city None
maxmind (default since 2.0) No Country + city Free MaxMind account + credentials
disabled No

Set via .env:

ANALYTICS_GEO_PROVIDER=ip-api      # external HTTP, no credentials needed
# ANALYTICS_GEO_PROVIDER=maxmind   # default — local database, no external calls
# ANALYTICS_GEO_PROVIDER=disabled  # disables geolocation entirely

If maxmind is active but credentials are not configured, a warning banner appears in the Control Panel dashboard and geolocation will silently return empty results. Geographic widgets (Top countries, Top cities) are hidden entirely when provider is disabled.

ip-api is an optional external provider, not the default. When active, visitor IP addresses are transmitted to ip-api.com with each uncached geolocation request. The 45 req/min rate limit is a constraint of the ip-api.com free tier, not a configuration choice of this addon. For full privacy with no external IP transmission, use maxmind (local database) or disabled.

MaxMind GeoLite2 (recommended for full privacy)

  1. Create a free account at maxmind.com/en/geolite2/signup
  2. Generate a license key in My Account → Manage License Keys
  3. Add to .env:
    ANALYTICS_GEO_PROVIDER=maxmind
    MAXMIND_ACCOUNT_ID=<your_account_id>
    MAXMIND_LICENSE_KEY=<your_license_key>
    
  4. Download the database:
    php artisan analytics:update-geoip

Important: analytics:update-geoip is not scheduled automatically by the addon — this is a deliberate choice. Triggering an automatic download to an external service is an infrastructure decision that belongs to each project, not to an addon. Add it yourself in routes/console.php at the frequency you prefer:

// routes/console.php
Schedule::command('analytics:update-geoip')->weekly()->tuesdays();

MaxMind updates GeoLite2 every Tuesday — a weekly schedule on Tuesday is recommended.

Static caching (full strategy)

When STATAMIC_STATIC_CACHING_STRATEGY=full is active, Statamic offloads pages to nginx as static HTML files. On cached requests, PHP is bypassed entirely. On cache misses (first request per URL), PHP does run — but the middleware detects strategy=full and skips tracking immediately, leaving the beacon JS as the sole tracking path.

The addon handles this transparently via a JS tracker tag. Add it once to your Antlers layout, alongside the consent banner if used:

{{ statamic_analytics:tracker }}
{{ statamic_analytics:consent_banner }}

How it works:

Scenario Tag renders Middleware
strategy=null or half (empty string) active — handles tracking
strategy=full inline <script> beacon self-disabled (early return, even on cache miss)

When the tag renders a script, the beacon fires after page load and sends a GET request to /statamic-analytics/track. The server then performs the same operations as the middleware: bot detection, geolocation, DB write.

What moves to localStorage / sessionStorage:

Data Storage Notes
visitor_id localStorage Persistent across sessions — more accurate new/returning detection than the session-based approach
session_id sessionStorage Resets on tab close
Visited pages (within session) sessionStorage Used for is_new_page_visit
Last visit date / hour localStorage Used for is_new_day_visit / is_new_hour_visit

Consent: If tracking.consent.enabled is true, the JS tracker reads analytics_consent from localStorage (the same key written by the consent banner) and silently aborts if consent has not been given. No identifier is created and no beacon is sent before consent.

Privacy note — persistent localStorage identifier: In strategy=full mode, the tracker writes _anl_vid to localStorage. Unlike the standard mode (where visitor_id lives in the Laravel session and is cleared when the session expires), this identifier persists across browser sessions and carries no expiry. It is a first-party identifier stored in the visitor's browser and transmitted only as part of the beacon sent to your own server — no analytics cookies are set. This is an architectural consequence of full static caching (PHP session is unavailable). Mention it in your privacy policy if your legal context requires it.

Cache invalidation: The tag output is baked into the static HTML file when the page is first cached. If you toggle the caching strategy, run php artisan statamic:static:clear to force regeneration.

Do not add this tag if strategy is not full. It auto-detects the strategy and renders nothing when the middleware is active, so including it unconditionally is safe and recommended.

Consent banner

When tracking.consent.enabled is true, tracking only starts after visitor consent.

Add to your Antlers layout:

{{ statamic_analytics:consent_banner }}
<meta name="csrf-token" content="{{ csrf_token }}">

Publish and customize the template:

php artisan vendor:publish --tag=statamic-analytics-views

Template location after publishing:

resources/views/vendor/statamic-analytics/components/consent-banner.antlers.html

Aggregate recalculation

The addon writes page views directly to the database on every request. A scheduled command recalculates aggregates (by country, device, browser, platform) for today and yesterday:

php artisan analytics:process

This runs automatically via Laravel Scheduler at the frequency defined in config. Make sure the scheduler is running:

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

If the scheduler was interrupted for several days, use --days to backfill the missing aggregates:

php artisan analytics:process --days=7

The default (--days=2, today + yesterday) is unchanged for automatic scheduling.

Asynchronous tracking (opt-in)

By default, page view recording (geolocation lookup + database write) happens synchronously during the HTTP request. For sites with very high traffic where the DB write or geolocation resolution becomes a measured bottleneck, you can offload this work to a Laravel queue worker.

For the vast majority of sites (low to medium traffic), this mode brings no measurable benefit. Only enable it when you have concrete evidence that synchronous recording is a bottleneck — not as a precaution.

What moves to the queue vs. what stays synchronous

Stays synchronous (HTTP request) Deferred to queue worker
Session mutations (visitor_id, visited_pages, flags) Geolocation lookup (IP → country/city)
User-agent parsing, device/browser detection Database INSERT
Referrer sanitization

Session data must be manipulated during the HTTP cycle. It cannot be safely accessed from a queue job running outside the request context.

Configuration

# .env
ANALYTICS_QUEUE_CONNECTION=redis   # any Laravel queue connection
ANALYTICS_QUEUE_NAME=analytics     # optional, defaults to "analytics"
// config/statamic-analytics.php
'tracking' => [
    'queue_connection' => env('ANALYTICS_QUEUE_CONNECTION', null), // null = synchronous (default)
    'queue_name'       => env('ANALYTICS_QUEUE_NAME', 'analytics'),
    // ... existing keys unchanged
],

queue_connection = null (the default) preserves the existing synchronous behaviour exactly. No worker is needed.

Prerequisites

A queue worker must be running, otherwise jobs accumulate in the queue storage without ever executing:

php artisan queue:work --queue=analytics

In production, supervise it with Supervisor or a systemd unit so it restarts automatically.

Multi-container / split worker deployments — if your web server and queue worker run in separate containers or machines, the MaxMind GeoLite2-City.mmdb file must be accessible on a shared volume mounted at the same path on both sides. Without it, geolocation silently falls back to emptyResult() (all geo fields remain null) — the visit is still recorded, but without country/city data.

Retry behaviour

Jobs use 3 attempts with an exponential backoff of 10 / 30 / 60 seconds. Definitive failures appear in Laravel's standard failed_jobs table.

Payload encryption

TrackPageViewJob implements ShouldBeEncrypted. The job payload — which may contain IP addresses, user-agents, and user IDs — is encrypted with APP_KEY before being pushed to the queue backend. This applies equally to failed_jobs entries written on definitive failure: no PII reaches the queue storage or the failed-jobs table in plaintext.

The analytics:purge-failed-jobs command automatically removes stale TrackPageViewJob entries from failed_jobs using the same ip_retention_days threshold, so encrypted payloads do not accumulate indefinitely.

Safety net

If queue dispatch fails (misconfigured connection, driver unavailable), the middleware catches the exception, logs an error, and falls back to synchronous recording. This does not cover post-dispatch failures (worker crash, job exhausting its retries) — those appear in failed_jobs and are handled by analytics:purge-failed-jobs:

StatamicAnalytics: queue dispatch failed, falling back to synchronous recording

IP retention

By default, ip_address, user_agent, and user_id are automatically set to NULL after 90 days. This keeps historical visit counts, page view aggregates, and visitor/session identifiers intact while removing personally identifiable data.

track_authenticated_users is enabled by default, so authenticated user IDs are recorded at visit time. However, the user_id link is automatically broken at the same retention threshold as the IP — it is therefore not a permanent association.

The analytics:anonymize-ips command runs daily via the Laravel Scheduler (auto-registered by the addon — no manual cron entry needed).

Configuration

# .env
ANALYTICS_IP_RETENTION_DAYS=90   # default
ANALYTICS_IP_RETENTION_DAYS=30   # shorter retention
ANALYTICS_IP_RETENTION_DAYS=null   # unlimited (not recommended) — the literal word "null" is required; a blank value is read as an empty string, not null, and would NOT disable anonymization

Manual run

# Preview affected rows without modifying anything
php artisan analytics:anonymize-ips --dry-run

# Run anonymisation immediately
php artisan analytics:anonymize-ips

What is NOT affected

  • Visit counts, unique visitor counts, and aggregate statistics — they rely on visitor_id/session_id, not on ip_address.
  • Already-resolved geolocation data (country_code, country_name, city) — only ip_address, user_agent, and user_id columns are set to NULL.

Raw event retention

By default, raw page view rows are permanently deleted after 180 days via the analytics:purge-raw-events command (runs daily, auto-registered by the addon).

This is distinct from IP anonymisation (ip_retention_days): anonymisation sets PII columns to NULL but keeps the row; purge removes the row entirely.

Configuration

# .env
ANALYTICS_RAW_RETENTION_DAYS=180   # default
ANALYTICS_RAW_RETENTION_DAYS=365   # longer window
ANALYTICS_RAW_RETENTION_DAYS=null  # unlimited (not recommended) — literal "null" required

Manual run

# Preview affected rows without deleting
php artisan analytics:purge-raw-events --dry-run

# Run purge immediately
php artisan analytics:purge-raw-events

What survives indefinitely (aggregate table)

The _overview dimension (written by analytics:process on each run) preserves per-day totals regardless of purge:

  • Total visits
  • Unique visitors
  • Unique page views
  • Returning visitors

Country, device, browser, and platform breakdowns are also preserved in the aggregate table.

What does NOT survive beyond the raw retention window

Widgets that query statamic_analytics_page_views directly will show empty data for dates older than the retention window:

  • Individual page performance (top pages, avg. time, exit rate)
  • Traffic sources and referrers
  • Hourly activity heatmap
  • Session depth
  • User flow (entry/exit pages)
  • Avg. session duration

Health check

php artisan analytics:health

Runs a series of diagnostic checks and prints a colour-coded summary:

Statamic Analytics — Health Check
──────────────────────────────────────────────────
  ✓ Configuration
  ✓ Database                 (42 381 events)
  ✓ Cache                    (redis)
  ✓ Encryption
  ✓ Queue                    (synchronous)
  ✓ MaxMind credentials
  ✓ MaxMind database         (3 days old)
  ✓ Storage permissions
  ⚠ Scheduler
    Last activity 26h ago — scheduler may not be running
  ✓ Failed jobs
  ✓ Static cache             (not enabled)
  ✓ Beacon endpoint
──────────────────────────────────────────────────
  1 warning(s) — review output above
Check What is verified
Configuration Config file loaded
Database Connection + tables present + event count
Cache Write / read / delete a temporary key
Encryption APP_KEY is set (required for encrypted job payloads)
Queue Connection exists in queue.connections; synchronous if null
Geolocation Provider configured; disabled → warning
MaxMind Credentials present, .mmdb file exists and readable, age ≤ 30 days
Storage permissions Analytics cache, logs, and geoip directories are writable
Scheduler analytics-scheduler.log exists and modified within the last 25 hours
Failed jobs Count of TrackPageViewJob entries in failed_jobs
Static cache full strategy → middleware self-disabled, beacon JS handles tracking
Beacon endpoint Route statamic-analytics.track is registered

The command exits with code 1 if any check fails (), making it usable in deployment pipelines and monitoring scripts. Warnings () do not affect the exit code.

Architecture

HTTP request (strategy ≠ full)
    └─ TrackPageVisit middleware
           ├─ $next($request) → executes the full request stack
           ├─ (skip if status ≠ 200 or Content-Type ≠ text/html)
           └─ INSERT into statamic_analytics_page_views   ← direct, real-time

Static HTML served by nginx (strategy=full)
    └─ <script> beacon ({{ statamic_analytics:tracker }})
           └─ GET /statamic-analytics/track  ← always PHP, never cached
                  ├─ bot detection, excluded IPs/paths
                  └─ INSERT into statamic_analytics_page_views

Scheduler (every N minutes)
    └─ analytics:process
           └─ DELETE + INSERT into statamic_analytics_aggregates
              (recalculated from page_views for today + yesterday)

Scheduler (daily)
    └─ analytics:anonymize-ips
           └─ UPDATE statamic_analytics_page_views
              SET ip_address = NULL, user_agent = NULL, user_id = NULL
              WHERE visited_at < now() - ip_retention_days

    └─ analytics:purge-raw-events
           └─ DELETE FROM statamic_analytics_page_views
              WHERE visited_at < now() - raw_retention_days

    └─ analytics:purge-failed-jobs
           └─ DELETE FROM failed_jobs
              WHERE failed_at < now() - ip_retention_days
              AND payload LIKE '%TrackPageViewJob%'

Geolocation (IP → country/city) is resolved by the configured provider — ip-api.com (HTTP, 45 req/min free tier) or MaxMind GeoLite2 (local .mmdb file, no external calls). Results are cached for 24 hours. With provider: disabled, country/city fields stay null and no IP leaves the server.

License

MIT — see LICENSE.md.

Original work © 2024 Mohammed Shuaau. Modifications © 2026 Olivier Petrucciani (OliWeb - oliweb.ch).