keyagency / kai-personalize
Adaptive content delivery based on visitor attributes and behavior
Package info
github.com/keyagency/kai-personalize
Type:statamic-addon
pkg:composer/keyagency/kai-personalize
Requires
- php: ^8.1
- geoip2/geoip2: ^3.0
- jenssegers/agent: ^2.6
- statamic/cms: ^6.0
Requires (Dev)
- orchestra/testbench: ^8.0
- phpunit/phpunit: ^10.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Adaptive content delivery based on visitor attributes and behavior
Overview
Kai Personalize is a professional Statamic add-on that enables you to deliver personalized content based on visitor attributes (browser, IP, location, device fingerprint) and external data sources (weather, news, etc.). Built by Key Agency with AI agent Kai.
Editions
Kai Personalize is available in two editions:
Lite Edition
Perfect for getting started with personalization:
- Visitor tracking & sessions
- Basic rules (max 5 active)
- Geolocation (MaxMind)
- API connections (max 2)
- Behavioral tracking (page views, scroll, clicks)
Pro Edition
Advanced features for growing businesses:
- Everything in Lite
- Unlimited rules & API connections
- Analytics dashboard & engagement scoring
- Dynamic segments
- ActiveCampaign integration
- Data export functionality
Upgrade to Pro at statamic.com/marketplace/addons/kai-personalize
Feature Comparison
| Feature | Lite | Pro |
|---|---|---|
| Visitor Tracking | ✅ | ✅ |
| Session Management | ✅ | ✅ |
| Browser Detection | ✅ | ✅ |
| Geolocation (MaxMind) | ✅ | ✅ |
| API Connections | 2 max | Unlimited |
| Personalization Rules | 5 max | Unlimited |
| Behavioral Tracking | ✅ | ✅ |
| Analytics Dashboard | ❌ | ✅ |
| Engagement Scoring | ❌ | ✅ |
| Dynamic Segments | ❌ | ✅ |
| ActiveCampaign Integration | ❌ | ✅ |
| Bot Blacklist | ✅ | ✅ |
| Data Export | ❌ | ✅ |
Current Status
Status: All core features complete and functional. Now compatible with Statamic 6! See the version badge above for the current release, and CHANGELOG.md for what changed.
✅ What's Working Now:
- ✅ Visitor tracking (server-side)
- ✅ Session management
- ✅ Database structure (12 tables)
- ✅ All Antlers tags
- ✅ API services (Weather, Geolocation, Custom)
- ✅ Artisan commands
- ✅ Complete Control Panel Interface:
- ✅ Dashboard with real-time statistics
- ✅ Analytics & Engagement Scoring (NEW)
- ✅ Rules management (CRUD, condition builder, statistics)
- ✅ Visitors management (browse, search, profiles, sessions, page history, behavioral summary)
- ✅ Segments (CRUD, visitor assignment, criteria builder)
- ✅ API Connections (CRUD, testing, cache management)
- ✅ Settings page (configuration overview)
- ✅ Privacy features (IP encryption, DNT, GDPR compliance)
🔨 Next Up:
- Enhanced dashboard with charts
- Export/Import functionality
- Segment-based condition support in Rules
Features
- Browser Detection: Comprehensive browser, device, and bot detection via jenssegers/agent
- Local Geolocation: Fast IP-to-location lookups using MaxMind GeoIP2 local databases (no API calls)
- Browser Fingerprinting: Advanced visitor identification using canvas, WebGL, audio, and more
- Session Management: Leverages Statamic's built-in session system with visitor tracking
- Behavioral Tracking: Monitor page views, time on site, referrers, and UTM parameters
- External API Integration: Connect to weather, news, exchange rates, and custom APIs
- ActiveCampaign Integration: Automatic email campaign visitor tracking and CRM data sync
- Bot Blacklist: Block unwanted bots and monitoring tools from polluting your analytics
- Minified JavaScript: Optimized tracker.js for faster page loads (62% smaller)
- Rule-Based Personalization: Create complex conditions to show different content
- Privacy Compliant: GDPR support, IP encryption, DNT respect, and data anonymization
- Multilingual: Full English and Dutch support
- Control Panel Interface: Comprehensive dashboard for managing all aspects
- Performance Optimized: Caching, queueing, and batch operations
Dependencies
- PHP: ^8.2
- Statamic: ^6.0
- jenssegers/agent: ^2.6 - Browser/device detection
- geoip2/geoip2: ^3.0 - MaxMind GeoIP2 local database reader
Quick Start
Once installed, you can immediately:
- View visitor data: Go to
/cp/kai-personalizein your Control Panel - Use Antlers tags: Add personalization to your templates (see examples below)
- Check settings: Configure features at
/cp/kai-personalize/settings
The addon automatically tracks visitors as they browse your site!
Installation
- Add the addon to your project:
composer require keyagency/kai-personalize
- Publish configuration, translations and assets:
php artisan vendor:publish --tag=kai-personalize-config php artisan vendor:publish --tag=kai-personalize-translations php artisan vendor:publish --tag=kai-personalize-assets
The assets tag holds the Control Panel stylesheet and the tracker script. Publishing lets your
webserver hand the tracker out directly; skip it and the addon falls back to serving the file
through PHP, which still works but costs a full framework boot per visitor. Republish with
--force after every upgrade, and add it to your deploy script — public/vendor is usually
gitignored, so a fresh release starts without it.
- Run migrations:
php artisan migrate
- Configure your API keys in
.env:
# External API keys GEOLOCATION_API_KEY=your_key_here WEATHER_API_KEY=your_key_here NEWS_API_KEY=your_key_here EXCHANGE_API_KEY=your_key_here # ActiveCampaign integration (optional) KAI_ACTIVECAMPAIGN_ENABLED=true KAI_ACTIVECAMPAIGN_URL=https://your-account.api-us1.com KAI_ACTIVECAMPAIGN_API_KEY=your_api_key_here KAI_ACTIVECAMPAIGN_COOKIE=vgo_ee KAI_ACTIVECAMPAIGN_CACHE_TTL=1440 # Tracker Queue Settings (optional) KAI_QUEUE_THRESHOLD=5 KAI_QUEUE_SEND_INTERVAL=20000 KAI_QUEUE_PERSIST=true KAI_QUEUE_STORAGE_KEY=kai_tracker_queue KAI_QUEUE_MAX_EVENT_AGE=3600000 # Security (optional but strongly recommended for production) KAI_TRACKING_ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com,*.yourdomain.com # Per-IP rate limits on the tracking endpoints (optional; 0 disables a window) KAI_TRACKING_RATE_LIMIT_PER_MINUTE=120 KAI_TRACKING_RATE_LIMIT_PER_HOUR=1000 # Bot Blacklist Configuration (enabled by default since 1.2.8) KAI_BLACKLIST_ENABLED=true KAI_BLACKLIST_LOGGING=true KAI_BLACKLIST_LOG_RETENTION=30 KAI_SKIP_KNOWN_BOTS=true # JavaScript tracker (use minified version by default) KAI_USE_MINIFIED_JS=true
- (Optional) Set up MaxMind GeoIP2 for local geolocation:
# Create the geoip directory mkdir -p storage/app/geoip # Download free GeoLite2 databases from: # https://dev.maxmind.com/geoip/geolite2-free-geolocation-data # Place the .mmdb files in storage/app/geoip/
- (Optional) Build minified JavaScript:
# Install dependencies and build npm install && npm run build # Or use composer script composer run build-js
The minified tracker.min.js (~62% smaller) is served automatically when available. Rebuild after modifying tracker.js.
Configuration
The configuration file is located at config/kai-personalize.php. Key settings include:
'features' => [ 'fingerprinting' => true, 'ip_tracking' => true, 'geolocation' => true, 'behavioral_tracking' => true, 'external_data' => true, ], 'privacy' => [ 'encrypt_ip' => true, 'anonymize_after_days' => 30, 'respect_dnt' => true, 'gdpr_mode' => false, ], // MaxMind GeoIP2 Local Database (no API calls needed) 'maxmind' => [ 'enabled' => true, 'database_city' => 'app/geoip/GeoLite2-City.mmdb', 'database_country' => 'app/geoip/GeoLite2-Country.mmdb', 'database_asn' => 'app/geoip/GeoLite2-ASN.mmdb', 'cache_duration' => 86400, ],
MaxMind GeoIP2 (Local Geolocation)
The addon uses MaxMind GeoIP2 for fast, local geolocation lookups without API calls.
Setup
- Create a free MaxMind account at https://www.maxmind.com/en/geolite2/signup
- Generate a license key in your MaxMind account
- Download the databases using the Artisan command:
# Download all databases php artisan kai:maxmind:download --license=YOUR_LICENSE_KEY # Or set the license key in .env and run without option # MAXMIND_LICENSE_KEY=your_license_key php artisan kai:maxmind:download # Download only specific database php artisan kai:maxmind:download --database=city php artisan kai:maxmind:download --database=country php artisan kai:maxmind:download --database=asn
Available databases:
- GeoLite2-City.mmdb - Full location data (country, region, city, postal, timezone, coordinates)
- GeoLite2-Country.mmdb - Country only (smaller, faster)
- GeoLite2-ASN.mmdb - ISP/organization info (optional)
The command downloads and extracts the .mmdb files to storage/app/geoip/
Configuration
# Enable/disable MaxMind KAI_MAXMIND_ENABLED=true # Database paths (relative to storage/) KAI_MAXMIND_CITY_DB=app/geoip/GeoLite2-City.mmdb KAI_MAXMIND_COUNTRY_DB=app/geoip/GeoLite2-Country.mmdb KAI_MAXMIND_ASN_DB=app/geoip/GeoLite2-ASN.mmdb # Cache duration in seconds (default: 24 hours) KAI_MAXMIND_CACHE=86400 # Optional: License key for automatic updates MAXMIND_LICENSE_KEY=your_license_key
Stored Attributes
When enabled, MaxMind stores these visitor attributes (type: external):
| Attribute | Example | Database |
|---|---|---|
country |
Netherlands | City/Country |
country_code |
NL | City/Country |
region |
North Holland | City |
region_code |
NH | City |
city |
Amsterdam | City |
postal_code |
1012 | City |
continent |
Europe | City/Country |
continent_code |
EU | City/Country |
timezone |
Europe/Amsterdam | City |
is_eu |
1 | City/Country |
latitude |
52.3676 | City |
longitude |
4.9041 | City |
isp |
KPN B.V. | ASN |
Note: Coordinates are not stored when
gdpr_modeis enabled.
ActiveCampaign Integration
The addon integrates with ActiveCampaign to automatically identify visitors from email campaigns and personalize content based on their CRM data.
How It Works
- User clicks link in ActiveCampaign email → lands on site with tracking cookie (
vgo_ee,__actc, etc.) - TrackVisitor middleware detects the AC cookie
- Server-side API call fetches contact data (tags, lists, custom fields)
- Data stored as visitor attributes (type:
crm) - Available via
{{ kai:visitor }}tag for personalization
Configuration
Add these settings to your .env file:
# Enable ActiveCampaign integration KAI_ACTIVECAMPAIGN_ENABLED=true # ActiveCampaign API credentials KAI_ACTIVECAMPAIGN_URL=https://your-account.api-us1.com KAI_ACTIVECAMPAIGN_API_KEY=your_api_key_here # Cookie name that contains the email (ActiveCampaign default) KAI_ACTIVECAMPAIGN_COOKIE=vgo_ee # Cache duration in minutes (default: 1440 = 24 hours) KAI_ACTIVECAMPAIGN_CACHE_TTL=1440
Stored Attributes
When a visitor arrives from an ActiveCampaign email, these attributes are stored (type: crm):
| Attribute | Type | Description |
|---|---|---|
ac_contact_id |
string | ActiveCampaign contact ID |
ac_email |
string | Contact email address |
ac_first_name |
string | First name |
ac_last_name |
string | Last name |
ac_phone |
string | Phone number |
ac_tags |
json | Array of tag names |
ac_lists |
json | Object with list membership status |
ac_custom_fields |
json | Custom field values |
ac_created_at |
timestamp | Account created date |
ac_updated_at |
timestamp | Last updated in AC |
Usage Examples
Personalize by Tag
{{ kai:visitor }}
{{ if ac_tags contains 'VIP' }}
<p>Welcome back, VIP member! Here's your exclusive content.</p>
{{ /if }}
{{ /kai:visitor }}
Personalize by List Membership
{{ kai:visitor }}
{{ if ac_lists.newsletter.status == 1 }}
<p>Thanks for being a subscriber!</p>
{{ /if }}
{{ /kai:visitor }}
Personalize by Custom Field
{{ kai:visitor }}
{{ if ac_custom_fields.member_level == 'Gold' }}
<p>Gold member exclusive benefits</p>
{{ /if }}
{{ /kai:visitor }}
Condition Tag with AC Data
{{ kai:condition attribute="ac_member_level" operator="equals" value="Gold" }}
<p>Gold member exclusive content</p>
{{ /kai:condition }}
Testing
Test the ActiveCampaign integration via command line:
# Test API connection php artisan kai:test-activecampaign # Test email lookup php artisan kai:test-activecampaign --email=user@example.com # Test cookie-based retrieval (interactive) php artisan kai:test-activecampaign --test-cookie
Cookie Decoding
The service automatically handles multiple ActiveCampaign cookie encoding formats:
- Base64 encoded email
- URL-encoded + Base64
- Plain text email
- URL-encoded email
The default cookie name is vgo_ee but can be configured via KAI_ACTIVECAMPAIGN_COOKIE. Alternative cookies (__actc, contact_email) are checked as fallbacks.
Privacy & GDPR
- Cookie consent - Only reads AC cookie if consent given (when
cookie_consent_requiredis enabled) - Data retention - Cache TTL respects AC rate limits (default 24 hours)
- Right to be forgotten - AC attributes are cleared when visitor data is deleted
- Logging - API calls are logged but sensitive data is masked
Bot Blacklist
The blacklist feature allows you to block specific bots and monitoring tools from being tracked, keeping your analytics clean and focused on real visitors.
Upgrading to 1.2.8? Two things changed here:
- The blacklist is now on by default. Prior to 1.2.8 a duplicate
blacklistkey in the config silently forced it off, so it was inactive even if you thought otherwise. SetKAI_BLACKLIST_ENABLED=falseif you want the old behaviour.- If you published the config before 1.2.4, your
config/kai-personalize.phphas noblacklistkey at all. Republish it withphp artisan vendor:publish --tag=kai-personalize-config --forceso the new options are present.
How It Works
- Blacklist entries are stored in the database and managed via the Control Panel
- When a request arrives, the blacklist service checks:
- Bot names (e.g., "semrush", "ahrefsbot")
- User agent patterns (e.g., "scrapy", "curl")
- If
KAI_SKIP_KNOWN_BOTSis on, any user agent the parser recognises as a bot is skipped too, so you do not have to maintain a pattern for every crawler — including the search and social crawlers - Blocked requests skip tracking but still access the site normally
- Requests matching a blacklist entry are logged with hit counts for analysis
The blacklist check runs before the entry lookup, so bot traffic never pays for resolving a Statamic entry. Patterns are cached (10 minutes by default) and the cache is invalidated automatically when you add, edit or delete an entry.
Why this matters: on a production site we measured 18% of all tracked visitors as recognised bots, each generating a visitor row plus ~29 attribute rows. Leaving the blacklist off is the single largest source of database growth.
Configuration
Add these settings to your .env file:
# Enable blacklist feature (default: true since 1.2.8) KAI_BLACKLIST_ENABLED=true # Enable logging of blocked requests KAI_BLACKLIST_LOGGING=true # How long to keep blacklist logs (default: 30 days) KAI_BLACKLIST_LOG_RETENTION=30 # Also skip user agents the parser recognises as a bot, without needing # a pattern for each one. Applies to search and social crawlers too. (default: true) KAI_SKIP_KNOWN_BOTS=true # Seconds to cache active patterns (default: 600) KAI_BLACKLIST_PATTERN_CACHE_TTL=600
Default Blacklist
The addon comes pre-seeded with common bots and tools:
SEO/Marketing Bots:
- Semrush, AhrefsBot, MJ12bot, DotBot
Monitoring/Uptime Services:
- UptimeRobot, Pingdom, StatusCake, Uptrends, Site24x7
AI Scrapers:
- ChatGPT, GPTBot, CCBot, Anthropic, Claude
User Agent Patterns:
- Scrapy, cURL, Wget, Python-requests, Go-http-client
Note: All pre-seeded entries are inactive by default. Enable them individually via the Control Panel.
Search and social crawlers
Googlebot, Bingbot, Slurp, DuckDuckBot, BaiduSpider, YandexBot, FacebookExternalHit, TwitterBot and LinkedInBot get the page exactly like any other visitor, and do not get a visitor row.
Changed in 1.3.0. These nine used to sit on a whitelist that returned early from the blacklist check, which skipped both the patterns and
KAI_SKIP_KNOWN_BOTS— so they were the only bots that were tracked. The whitelist was meant to prevent cloaking, but this addon never blocks a request: the check only decides whether to record a visitor. Since a crawler runs no JavaScript it never gets past thetemp_placeholder, so every request left a dead row behind. On one production install 2.921 of 3.538 visitors (83%) were Facebook's link-preview crawler.Set
KAI_SKIP_KNOWN_BOTS=falseif you want recognised bots in your statistics.
Management via Control Panel
Navigate to Kai Personalize > Blacklist in your Control Panel to:
- View all entries - Type, pattern, description, status, hit count
- Add new entries - Bot name or user agent pattern
- Toggle active status - Click the badge to enable/disable
- View logs - See blocked requests with details
- Edit/Delete entries - Full CRUD operations
Seeding the Database
To populate the default blacklist entries, run the seeder:
php artisan db:seed --class=KeyAgency\\\\KaiPersonalize\\\\Database\\\\Seeders\\\\BlacklistSeeder
Or add it to your main DatabaseSeeder:
$this->call(BlacklistSeeder::class);
Browser Detection
The addon uses jenssegers/agent for comprehensive browser and device detection.
Stored Attributes
These attributes are automatically stored for each visitor (type: technical):
| Attribute | Example | Description |
|---|---|---|
browser |
Chrome | Browser name |
browser_version |
120.0.0.0 | Full version string |
browser_version_major |
120 | Major version number |
platform |
OS X | Operating system |
platform_version |
10_15_7 | OS version |
device |
Macintosh | Device name |
device_type |
desktop | mobile, tablet, or desktop |
is_mobile |
0 | Is mobile device (includes tablets) |
is_tablet |
0 | Is tablet |
is_desktop |
1 | Is desktop |
is_phone |
0 | Is phone (mobile but not tablet) |
is_bot |
0 | Is bot/crawler |
bot_name |
Googlebot | Bot name (if detected) |
accepted_languages |
en,nl | From Accept-Language header |
Conditional Content by Browser/Device
{{# Show different content for mobile users #}} {{ kai:condition attribute="is_mobile" operator="equals" value="1" }} <a href="tel:+31201234567">Call us</a> {{ /kai:condition }} {{# Target specific browsers #}} {{ kai:condition attribute="browser" operator="equals" value="Safari" }} <p>You're using Safari!</p> {{ /kai:condition }} {{# Hide content from bots #}} {{ kai:condition attribute="is_bot" operator="equals" value="0" }} <div class="tracking-pixel">...</div> {{ /kai:condition }}
Antlers Tags
kai:visitor
Get information about the current visitor:
{{ kai:visitor }}
{{# Basic info #}}
{{ fingerprint }}
{{ session_id }}
{{ ip_address }}
{{ visit_count }}
{{ is_returning }}
{{ first_visit }}
{{ last_visit }}
{{# Browser info (via jenssegers/agent) #}}
{{ browser }} {{# Chrome, Firefox, Safari, Edge, etc. #}}
{{ browser_version }} {{# Full version: 120.0.0.0 #}}
{{ browser_version_major }} {{# Major version: 120 #}}
{{# Platform/OS info #}}
{{ platform }} {{# Windows, OS X, Linux, Android, iOS #}}
{{ platform_version }} {{# OS version #}}
{{# Device info #}}
{{ device }} {{# iPhone, iPad, Macintosh, etc. #}}
{{ device_type }} {{# mobile, tablet, desktop #}}
{{ is_mobile }} {{# true/false #}}
{{ is_tablet }} {{# true/false #}}
{{ is_desktop }} {{# true/false #}}
{{ is_phone }} {{# true/false (mobile but not tablet) #}}
{{# Bot detection #}}
{{ is_bot }} {{# true/false #}}
{{ bot_name }} {{# Googlebot, bingbot, etc. #}}
{{# Geolocation (via MaxMind) #}}
{{ country }}
{{ country_code }}
{{ region }}
{{ city }}
{{ postal_code }}
{{ timezone }}
{{ continent }}
{{ is_eu }}
{{ latitude }}
{{ longitude }}
{{ google_maps_link }} {{# Derived from the coordinates #}}
{{# Time-based (derived on read, never stored) #}}
{{ time_of_day }} {{# Current hour: 00-23 #}}
{{ day_of_week }} {{# 0 (Sunday) - 6 (Saturday) #}}
{{# Traffic source #}}
{{ referrer }}
{{ utm_source }}
{{ utm_medium }}
{{ utm_campaign }}
{{ utm_term }}
{{ utm_content }}
{{# Languages #}}
{{ language }}
{{ accepted_languages }}
{{# ActiveCampaign (if enabled and visitor from email) #}}
{{ ac_contact_id }}
{{ ac_email }}
{{ ac_first_name }}
{{ ac_last_name }}
{{ ac_phone }}
{{ ac_tags }} {{# Array of tag names #}}
{{ ac_lists }} {{# Object with list status #}}
{{ ac_custom_fields }} {{# Custom field values #}}
{{ ac_created_at }}
{{ ac_updated_at }}
{{ /kai:visitor }}
Derived values (since 1.2.8):
google_maps_link,time_of_dayandday_of_weekare computed when you read them instead of being written to the database. They behave exactly as before in your templates, but no longer cost a row per visitor.time_of_dayandday_of_weekwere already computed live bykai:conditionandkai:content, so storing them was pure overhead — they were rewritten on every single page view.
kai:condition
Show content based on conditions:
{{ kai:condition attribute="country" operator="equals" value="US" }}
<p>Content for US visitors</p>
{{ /kai:condition }}
{{ kai:condition attribute="device_type" operator="equals" value="mobile" }}
<p>Mobile-specific content</p>
{{ /kai:condition }}
{{ kai:condition attribute="visit_count" operator="greater_than" value="5" }}
<p>Welcome back, loyal visitor!</p>
{{ /kai:condition }}
kai:external
Fetch data from external APIs:
{{# Weather API #}} {{ kai:external source="weather" location="Amsterdam" }} <p>Temperature: {{ temperature }}°C</p> <p>Condition: {{ condition }}</p> {{ /kai:external }} {{# Geolocation #}} {{ kai:external source="geolocation" }} <p>You are in {{ city }}, {{ country }}</p> {{ /kai:external }} {{# Custom API #}} {{ kai:external source="custom" connection="my-api" endpoint="/data" params:id="123" }} {{ response_data }} {{ /kai:external }}
kai:content
Display content based on rules:
{{ kai:content rules="homepage-hero" fallback="default" }}
{{ if condition_met }}
<h1>Personalized Hero Content</h1>
{{ else }}
<h1>Default Hero Content</h1>
{{ /if }}
{{ /kai:content }}
kai:session
Manage session data:
{{# Set session data #}} {{ kai:session:set key="preference" value="dark_mode" }} {{# Get session data #}} {{ kai:session:get key="preference" }} {{# Check if visitor is tracked #}} {{ if {kai:session:tracked} }} <p>We know you!</p> {{ /if }}
kai:api
Make direct API calls with caching:
{{ kai:api
url="https://api.example.com/data"
method="GET"
cache="600"
params:category="news"
}}
{{ results }}
{{ /kai:api }}
kai:track
Outputs the client-side tracking script for behavioral analytics. This enables automatic tracking of user interactions without requiring manual event tagging.
Usage
Add to your main layout file (typically in <head> or before closing </body>):
{{! In your layout file, e.g., resources/views/layouts/layout.antlers.html }}
<!DOCTYPE html>
<html>
<head>
{{! Other head content }}
{{ kai:track }}
</head>
<body>
{{! Or place it before closing body }}
{{ kai:track }}
</body>
</html>
What It Tracks
When enabled, the tracker automatically captures:
| Feature | Description |
|---|---|
| Page Views | URL, title, referrer, screen dimensions |
| Scroll Depth | Thresholds: 25%, 50%, 75%, 90%, 100% |
| Reading Time | Active reading time per page |
| Clicks | All clicks with element, position, and hesitation time |
| Rage Clicks | 3+ clicks on same element within 2 seconds |
| Dead Clicks | Clicks on non-interactive elements |
| Visibility | Page visibility changes (hidden/visible/pagehide) |
| Exit Intent | Mouse leaving viewport (potential exit) |
| Idle Detection | No activity for 60+ seconds |
| Device | Viewport, screen, touch, connection info |
| Preferences | Dark mode, reduced motion, language, timezone |
| Fingerprint | Browser fingerprint (canvas, WebGL, audio) |
Configuration
Features are controlled via config/kai-personalize.php:
'features' => [ 'scroll_tracking' => true, // Scroll depth, reading time, exit intent 'click_tracking' => true, // Clicks, rage clicks, dead clicks, hesitation 'form_tracking' => false, // Form interactions 'video_tracking' => false, // Video engagement 'fingerprinting' => true, // Browser fingerprinting ],
Master Switch
To completely disable tracking:
KAI_PERSONALIZE_ENABLED=false
Or in config:
'enabled' => env('KAI_PERSONALIZE_ENABLED', true),
When disabled, the tag outputs nothing.
Privacy
The tracker respects:
- DNT header - Stops tracking if
navigator.doNotTrack === '1' - Cookie consent - Checks for common consent cookie implementations
- Custom consent callback - Use
window.KaiConsentCallbackfunction for custom logic
JavaScript API
The tracker exposes a global API for manual control:
// Check if tracking is enabled if (window.KaiTracker.hasConsent()) { // Manual event tracking window.KaiTracker.track('custom_event', { my_data: 'value' }); // Force send queued events window.KaiTracker.send(); }
How It Works
- The tag outputs a config script with visitor/session IDs
- Loads
tracker.jsfrom/kai-personalize/tracker.js - Events are queued and batched (configurable threshold and interval)
- Queue is persisted to localStorage (survives page refreshes)
- Uses
sendBeaconfor reliable delivery on page unload - Cached for 1 day on the client
Queue Configuration
The tracker uses intelligent queue management to ensure reliable event delivery:
| Setting | Default | Description |
|---|---|---|
threshold |
5 events | Auto-send when queue reaches this size |
sendInterval |
20000 ms (20s) | Periodic send interval |
persistQueue |
true | Enable localStorage persistence |
storageKey |
kai_tracker_queue |
localStorage key name |
maxEventAge |
3600000 ms (1h) | Maximum event age before discarding |
Configure via .env:
KAI_QUEUE_THRESHOLD=5 # Send after 5 events KAI_QUEUE_SEND_INTERVAL=20000 # Send every 20 seconds KAI_QUEUE_PERSIST=true # Persist to localStorage KAI_QUEUE_STORAGE_KEY=kai_tracker_queue KAI_QUEUE_MAX_EVENT_AGE=3600000 # Discard events older than 1 hour
localStorage Persistence:
- Events are saved to localStorage as they're queued
- Survives page refreshes and navigation
- Automatically restored on page load
- Stale events (older than
maxEventAge) are discarded - Gracefully handles quota exceeded errors
kai:behavior
Get behavioral statistics for the current visitor:
{{ kai:behavior }}
{{ max_scroll_depth }}
{{ total_reading_time_ms }}
{{ total_clicks }}
{{ total_events }}
{{ /kai:behavior }}
kai:tracking
Returns the tracking endpoint and whether behavioural tracking is on.
{{ kai:tracking }}
{{ url }} {{! The tracking endpoint }}
{{ enabled }} {{! Whether behavioural tracking is on }}
{{ /kai:tracking }}
You rarely need this — {{ kai:track }} renders the tracker and wires up the endpoint itself.
Tracking endpoint security
The tracking endpoint needs no setup in the host application. It is exempt from CSRF
verification by the addon itself (routes/actions.php), because the tracker sends its final
batch with navigator.sendBeacon() on page unload — which cannot set headers — and events must
keep arriving after a visitor's session has expired.
Requests are guarded two ways instead:
- Origin/referer check —
KAI_TRACKING_ALLOWED_ORIGINS(see Configuration). Leave it empty and every origin is accepted, which is fine locally but not in production. - Rate limiting — per-IP limits per minute and per hour, in
ThrottleTracking. Both are configurable:KAI_TRACKING_RATE_LIMIT_PER_MINUTE(default 120) andKAI_TRACKING_RATE_LIMIT_PER_HOUR(default 1000), with0to switch a window off. The IP comes from$request->ip(), so behind a proxy you need trusted proxies configured — see below. Bear in mind that one visit costs several requests: the tracker batches its events.
Upgrading from < 1.2.12? The rate limit counters of earlier versions never expired on a file cache store, which left an IP on
429for good once it had passed the limit. Runphp artisan cache:clearafter upgrading to drop those entries; the new counters use different cache keys and expire on their own.
Upgrading from < 1.2.9? Earlier versions asked you to add a
validateCsrfTokens(except: …)rule tobootstrap/app.php. That is no longer needed and the rule can be removed. The HMAC signature layer andKAI_TRACKING_SECRETwere dropped in the same release.
Cloudflare Configuration
When using Cloudflare (or any reverse proxy/load balancer), you must configure trusted proxies to ensure correct IP address detection for visitor tracking.
Trusting Cloudflare Proxies
Earlier versions of this README told you to set
TRUSTED_PROXIESin.env. That does nothing on Laravel 11 and 12 — no middleware reads that variable any more. Trusted proxies are configured inbootstrap/app.php.
Add this to bootstrap/app.php, with Cloudflare's ranges in
config/trusted-proxies.php:
use Illuminate\Http\Request; ->withMiddleware(function (Middleware $middleware) { $middleware->trustProxies( // Read the file directly: this closure runs before the config service // is bound, so config() is not available here yet. at: (require __DIR__.'/../config/trusted-proxies.php')['cloudflare'], headers: Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO, ); })
Use an explicit list, not '*'. Symfony honours the X-Forwarded-* headers
only when REMOTE_ADDR is in the list, which is what keeps this safe on an
installation where some hosts sit behind Cloudflare and others are served
directly. With '*' any visitor on a directly served host can send an
X-Forwarded-For of their choosing and get fabricated geolocation plus a fresh
rate-limit bucket per request.
The current ranges are published at https://www.cloudflare.com/ips-v4 and
/ips-v6. They change rarely; re-check them when geolocation starts looking
wrong.
Why This Is Needed
Without trusting proxies, Laravel/Statamic will see Cloudflare's IP addresses instead of your visitors' real IP addresses, causing:
- Incorrect geolocation data
- All visitors appearing from the same location
- Rate limiting to affect all users collectively
- Visitor tracking to be less accurate
Verifying IP Detection
After configuring trusted proxies, verify that visitor IPs are correctly detected:
# Check your current IP curl https://ifconfig.me # In your Statamic app, check the detected IP # Add temporary debug code or use the kai:visitor tag {{ kai:visitor }}{{ ip_address }}{{ /kai:visitor }}
The IP displayed by the addon should match your actual IP address, not Cloudflare's.
API Connections
Built-in Providers
The addon supports these built-in API providers:
Weather APIs:
- OpenWeatherMap
- WeatherAPI
- AccuWeather
Geolocation APIs:
- IPapi
- MaxMind GeoIP2
- IP2Location
- ipstack
Custom API Connections
Manage your external API connections via the Control Panel:
- Navigate to Kai Personalize > API Connections
- Click Create Connection
- Fill in the details:
- Name
- Provider type (Weather, Geolocation, News, Exchange, Custom)
- API URL
- Authentication (None, API key, Bearer, Basic, OAuth2, Custom)
- Rate limits
- Cache duration
- Custom headers (optional)
- Test the connection to ensure it works
- View statistics: Total requests, success rate, cache usage
- Manage cache: Clear cached responses when needed
Testing Connections
Test API connections from the command line:
php artisan kai:test-api connection-name
Artisan Commands
# Download MaxMind GeoLite2 databases php artisan kai:maxmind:download --license=YOUR_LICENSE_KEY php artisan kai:maxmind:download --database=city # Download only city database php artisan kai:maxmind:download # Uses MAXMIND_LICENSE_KEY from .env # Test MaxMind database lookup php artisan kai:maxmind:test # Test with default IP (95.97.1.234) php artisan kai:maxmind:test 8.8.8.8 # Test with specific IP php artisan kai:maxmind:test --info # Show database info only # Clean up old visitor data php artisan kai:cleanup --days=30 # Remove provisional visitors that never became a real identity: crawler hits # and the emptied leftovers of a merge. Reports by default, deletes with --apply. php please kai:cleanup-fingerprints # report only php please kai:cleanup-fingerprints --apply # actually delete php please kai:cleanup-fingerprints --unidentified # also non-bot temp_ rows php please kai:cleanup-fingerprints --min-age=30 # widen the safety window # Test an API connection php artisan kai:test-api my-connection # Refresh API cache php artisan kai:refresh-cache --all php artisan kai:refresh-cache connection-name # Prune old API logs php artisan kai:prune-logs --days=30 # Test ActiveCampaign integration php artisan kai:test-activecampaign # Test connection php artisan kai:test-activecampaign --email=user@example.com # Test email lookup php artisan kai:test-activecampaign --test-cookie # Test cookie retrieval
Visitor Identity
A visitor row is keyed on fingerprint_hash, and that column holds one of three
forms. Which one you see tells you how much the identity is worth:
| Form | Example | Meaning |
|---|---|---|
v2_ + 32 hex |
v2_e8eaefd6… |
A random identifier the browser stores. Stable and collision-free. |
fp2_ + 8 hex |
fp2_cf4fe6e0 |
A fingerprint computed from device properties. Fallback only. |
temp_ + session id |
temp_4h3Dwg… |
A server-side placeholder; no client has reported in yet. |
The stored identifier is the primary key of a visitor's identity. The tracker
generates it once, keeps it in localStorage under kai_visitor, and sends it as
visitor_key with every batch. The server prefers it over the fingerprint.
The computed fingerprint is a fallback, not an identity. It is derived from
canvas, WebGL, screen size, colour depth, timezone, language and platform — and
those collide. Safari on iOS withholds canvas and the WebGL debug extension, so
every visitor on that platform with the same screen and locale lands on one value.
On a production install 50 fingerprints covered 55% of all sessions, one of them
spanning 58 different user agents. Treat a fp2_ visitor as "probably several
people" and a v2_ visitor as one.
A visitor falls back to fp2_ when storage is unavailable — a private window,
blocked storage, or consent that was refused (the identifier is only stored with
consent; see Privacy & GDPR).
temp_ rows are not visitors. They are placeholders waiting for the tracker
to report in, and a client that never runs JavaScript — a crawler, a browser with
scripting off — leaves one behind per request. kai:cleanup-fingerprints removes
the ones that never became anything.
Database Structure
The addon uses the following tables (all prefixed with kai_personalize_):
- visitors - Unique visitors, keyed on
fingerprint_hash(see Visitor Identity) - visitor_sessions - Individual browsing sessions
- visitor_attributes - Custom visitor attributes
- page_views - Page view tracking with entry metadata
- events - Behavioral events (scroll depth, clicks, reading time, etc.)
- rules - Personalization rules and conditions
- segments - Visitor segments with criteria
- segment_visitor - Pivot table for visitor-segment relationships
- logs - Personalization event logs
- api_connections - External API configurations
- api_cache - Cached API responses
- api_logs - API request logs
Privacy & GDPR
The addon includes several privacy features:
- IP Encryption: Automatically encrypt stored IP addresses
- Do Not Track: Respect DNT browser headers
- Data Anonymization: Automatically anonymize data after specified period
- GDPR Mode: Additional privacy controls for EU compliance
- Cookie Consent: Detects the consent tool on the page (see below)
- Data Retention: Configurable retention periods for all data types
- Right to be Forgotten: Delete visitor data via CP or API
Cookie consent
The tracker asks, in this order:
window.KaiConsentCallback— define this and it decides, full stop. Use it for a consent tool the addon does not know.- Cookiebot, through
Cookiebot.consent.statistics. It also listens forCookiebotOnAcceptandCookiebotOnDecline, so consent given after page load starts tracking without a reload, and withdrawing it clears the queue and the stored identifier. - The consent cookies of a few other tools (
cookie_consent,cookieconsent_status,cc_cookie,catConsent), where an explicitfalse/denycounts as a refusal. - Nothing found: tracking continues, unless
KAI_COOKIE_CONSENT_REQUIRED=true.
A site without a consent tool is unaffected — step 2 only runs when Cookiebot
is actually present. A tool that is present and says no is always honoured,
whatever KAI_COOKIE_CONSENT_REQUIRED is set to; that setting only decides what
happens when nobody answers.
# Require an explicit yes before tracking anything. Default: false. KAI_COOKIE_CONSENT_REQUIRED=true
The setting drives both sides: the tracker in the browser and TrackVisitor on
the server, which otherwise creates a visitor row before the banner is answered.
The
v2_identifier is only written tolocalStorageonce consent is given. Without it a visitor falls back to the computed fingerprint, which is weaker — see Visitor Identity.
Tracking Security
The addon includes multiple layers of protection to prevent data pollution and abuse:
Built-in Protections
| Protection | Description | Default |
|---|---|---|
| Rate Limiting | Max 60 requests/minute, 500/hour per IP | ✅ Enabled |
| Input Sanitization | Event types validated, HTML stripped, whitelist keys | ✅ Enabled |
| Max Events | Maximum 50 events per request | ✅ Enabled |
| Event Type Regex | Only alphanumeric + underscore allowed | ✅ Enabled |
| Origin Validation | Whitelist allowed domains | ⚠️ Optional |
Restricting Origins (Recommended for Production)
The tracking endpoint accepts requests from any origin until you list the ones you trust:
# Comma-separated; a *. wildcard also matches the bare domain KAI_TRACKING_ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com,*.yourdomain.com
Every host that serves pages carrying the tracker has to be in this list — including each
subdomain and any additional domain. A host that is missing gets its requests rejected with
403 Invalid origin, logged as Kai tracking: Invalid referer.
Security Best Practices
- Always use HTTPS - Tracking payloads can be read over HTTP
- Set allowed origins - Restricts cross-origin requests
- Monitor logs - Rejected origins and rate-limit hits are logged
- Use a WAF - CloudFlare or similar for DDoS protection
Performance
The addon is optimized for performance:
- Caching: Redis/file-based caching for API responses and visitor data
- Queueing: Optional queue support for heavy operations
- Batch Operations: Efficient bulk inserts and updates
- CDN-Friendly: Static asset delivery
- Rate Limiting: Prevent API overuse
- Circuit Breaker: Graceful degradation when APIs fail
Multilingual Support
The addon is fully translated in:
- English (en)
- Dutch (nl)
All Control Panel text, error messages, and documentation are available in both languages. The addon automatically detects the current site locale.
Control Panel
Access the Control Panel interface at /cp/kai-personalize:
Tools tab
Settings → Tools runs three maintenance commands from the browser, for environments without shell access:
| Tool | Runs |
|---|---|
| Remove provisional visitors | kai:cleanup-fingerprints --apply |
| Clear API cache | kai:refresh-cache --all |
| Delete visitor data older than 30 days | kai:cleanup --days=30 --force |
Requires the Manage Settings permission. The two destructive tools ask for confirmation first, and the command output is shown as-is.
The browser sends a short slug, never a command string, and the arguments are part of the server-side definition. There is no way to reach a command that is not one of these three, or to hand one an argument it was not given here —
--allon the cleanup would wipe everything rather than 30 days.
✅ Fully Implemented:
- Dashboard: Overview of visitors, sessions, top pages, and top engaged visitors, scoped by a period toggle (today / last 7 days / last 30 days / all time, defaulting to today)
- Analytics: Page-level analytics with views, unique visitors, scroll depth, and reading time
- Rules: Create and manage personalization rules with condition builder
- Visitors: Browse visitor profiles with engagement scores, page history, behavioral summary, sessions, and attributes
- Segments: Create dynamic visitor segments with criteria-based assignment
- API Connections: Manage external API integrations with testing and cache management
- Settings: Configure features, privacy, and performance
Analytics & Engagement Scoring
The addon now includes comprehensive analytics and engagement tracking:
Engagement Score (0-100)
Each visitor receives an engagement score based on:
- Visit Frequency (0-30 points):
sessions × 3, max 30 - Page Views (0-25 points):
page_views × 2, max 25 - Reading Time (0-25 points): For every 10 seconds of reading time, 1 point, max 25
- Scroll Depth (0-20 points): Max scroll depth / 5, max 20
All four components are measured within the selected period, so the score answers "how engaged is this visitor right now", not "how engaged have they ever been".
Visit frequency counts session rows rather than the visit_count column. That column
is an all-time total and cannot be scoped to a window, so mixing the two would make
the same data score differently depending on the period selected — "all time" could
even rank lower than "last 30 days".
visit_countitself counts visits, not page views: it is incremented once per session. Before 1.3.0 it was raised inside the per-page-view path, which only looked right because a stale session key made every page view a new visitor row. Values recorded before 1.3.0 are not comparable with newer ones.
Color-coded badges:
- 🟢 Green (70-100): Highly engaged
- 🟡 Yellow (40-69): Moderately engaged
- ⚪ Gray (0-39): Low engagement
Behavioral Summary
For each visitor, track:
- Max Scroll Depth: Deepest scroll percentage recorded
- Reading Time: Total time spent reading (in minutes)
- Total Clicks: Number of click events tracked
- Total Events: All behavioral events combined
Page Analytics
Navigate to Analytics > Pages to see:
- Total views per page
- Unique visitors per page
- First and last view timestamps
- Average scroll depth
- Average reading time
- Recent views with visitor links
Visitor Page History
Each visitor profile now includes:
- Complete browsing history with pagination
- Entry title and collection
- URL path
- View timestamp
Available Event Types
The addon tracks these behavioral events via the kai:track tag:
scroll_depth- Maximum scroll percentage on a pageclick- Click events on elementsvisibility- Element visibility trackingreading_time- Time spent reading contentcustom- Custom events
Example Use Cases
Weather-Based Content
{{ kai:external source="weather" }}
{{ if condition == "Rain" }}
<div class="promo">Don't forget your umbrella! ☔</div>
{{ /if }}
{{ /kai:external }}
Returning Visitor Welcome
{{ kai:visitor }}
{{ if is_returning }}
<h1>Welcome back!</h1>
<p>This is visit #{{ visit_count }}</p>
{{ else }}
<h1>Welcome!</h1>
<p>First time here?</p>
{{ /if }}
{{ /kai:visitor }}
Location-Based Content
{{ kai:condition attribute="country" operator="equals" value="US" }}
<p>Welcome! This is US-specific content.</p>
{{ /kai:condition }}
{{ kai:condition attribute="country" operator="not_equals" value="US" }}
<p>Welcome! This is international content.</p>
{{ /kai:condition }}
Device-Specific CTAs
{{ kai:condition attribute="device_type" operator="equals" value="mobile" }}
<a href="tel:+31201234567" class="cta">Call Us Now</a>
{{ /kai:condition }}
{{ kai:condition attribute="device_type" operator="equals" value="desktop" }}
<a href="/contact" class="cta">Contact Us</a>
{{ /kai:condition }}
Development
Local Development Setup
When developing the addon locally, use Composer's path repository to symlink the addon directory. Changes reflect instantly, and you commit in the addon repo separately.
Complete Setup
- Add path repository to your project's
composer.json:
{
"repositories": [
{
"type": "path",
"url": "/Users/remko/Sites/_plugins/kai-personalize"
}
],
"require": {
"keyagency/kai-personalize": "dev-main"
}
}
Note: The path repository tells Composer "this package is right here on my disk." Using just
"dev-main"without the path repository makes Composer look for the package on Packagist.
- Install the addon:
composer update keyagency/kai-personalize
- Verify the symlink was created:
ls -la vendor/keyagency/kai-personalize
# Should show: kai-personalize -> /Users/remko/Sites/_plugins/kai-personalize
- Develop normally - Changes in the addon directory reflect instantly in your project. Commit in the addon repo separately.
This is the standard Statamic addon development workflow recommended by the Statamic team.
Running Tests
composer test
Code Style
composer format
Support
For support, please contact:
- Email: info@keyagency.nl
- Website: https://keyagency.nl
License
Proprietary - Copyright © Key Agency
Credits
Developed by Key Agency with AI agent Kai.
Roadmap
✅ Completed (v1.0)
- Control Panel UI for Rules management
- Control Panel UI for Visitors management
- Control Panel UI for Segments management
- Control Panel UI for API Connections management
- Dashboard with real-time statistics
- Settings management interface
✅ Completed (v1.1)
- Analytics & Engagement Scoring
- Page-level analytics with scroll depth and reading time
- Visitor page history with pagination
- Behavioral summary (max scroll, reading time, clicks, events)
- Top engaged visitors ranking
- Event tracking tags (kai:track, kai:behavior)
Short Term (Next Release)
- Enhanced Dashboard with charts and graphs
- Export/Import functionality for rules and settings
- Segment-based condition support in Rules
Medium Term
- A/B testing capabilities
- Rule templates and presets
- Visitor journey visualization
- Segment-based condition support in Rules
Long Term
- Machine learning predictions
- GraphQL API support
- WebSocket connections for real-time data
- Content recommendation engine
- Multi-variant testing
- More API provider integrations