1.3.1
August 28th, 2026
- New A Tools tab under Settings runs three maintenance commands from the browser - for environments without shell access:
kai:cleanup-fingerprints --apply,kai:refresh-cache --allandkai:cleanup --days=30. Requires the Manage Settings permission, asks for confirmation on the two that delete data, and shows the command output as-is. The browser sends a short slug and never a command string, and the arguments are part of the server-side definition — so there is no path to a command outside these three, and no way to hand one an argument it was not given here (--allon the cleanup would wipe everything instead of 30 days).kai:cleanupis invoked with--force, since it otherwise waits for a console answer that cannot come
1.3.0
August 28th, 2026
- [breaking] Visitors are recognised by a stored identifier instead of their device - the computed fingerprint put everyone with the same screen, locale and timezone on one value: Safari on iOS withholds canvas and the WebGL debug extension, leaving a handful of coarse properties. On production 50 fingerprints covered 55% of all sessions, one of them spanning 58 different user agents - and that record ranked in the engagement top 10. The tracker now stores a random
v2_identifier and sends it alongside the fingerprint; the server prefers it. Missing components also keep their slot in the fingerprint string, which stops a browser without canvas from colliding with a different one that has it. Unique visitor counts go up and the engagement top 10 changes - [breaking] The tracker respects the cookie banner -
hasConsent()knew four consent cookie names, none of which Cookiebot writes, and noKaiConsentCallbackwas defined, so it fell through toreturn true: on a site with Cookiebot the tracker ran regardless of what the visitor chose. Cookiebot is now detected throughCookiebot.consent.statistics, withCookiebotOnAccept/OnDeclineso consent given after page load still starts tracking and withdrawing it drops the queue. A site without a consent tool is unaffected.privacy.cookie_consent_requiredfinally readsKAI_COOKIE_CONSENT_REQUIRED- that variable was never wired to anenv()call and did nothing at all - Fix Nine bots sat on a whitelist that switched tracking on rather than off -
BlacklistService::isWhitelistedBot()returned early fromshouldBlock()for googlebot, facebookexternalhit, twitterbot and six others, so neither the 23 blacklist patterns nor theskip_known_botscatch-all was ever reached andKAI_SKIP_KNOWN_BOTS=truedid nothing for them. The whitelist guarded against cloaking, which this addon does not do:shouldBlock()has one caller and it only ever skips tracking, never the response. A crawler that runs no JavaScript can never leave thetemp_state, so each request left a dead row behind. 2.921 of 3.538 production visitors (83% of the table) were Facebook's link-preview crawler. Bots now get the page and no visitor row; the method isshouldTrack()so the two meanings cannot blur again - Fix Every page view created a visitor and merged it away again -
TrackVisitorwrotetemp_<session id>into the session key, and the tracking endpoint renamed that row to the real fingerprint without updating it. The next page view looked up a name that no longer existed, created a fresh row, and the endpoint merged and deleted it. 4.230 merge lines in one production log, and one visitor with 111 sessions againstvisit_count1. The middleware now falls back to the session row, which survives the rename, and the endpoint writes the current hash back into the session - Fix The dashboard took 19,8 seconds and 10.660 queries to draw a top-10 list -
DashboardController::getTopEngagedVisitors()fetched every visitor and calledVisitor::engagementScore()on each one, which issued three queries of its own, and did so twice per visitor: once to sort, once to map. Measured on a production install with 3.602 visitors:1 + 3Nqueries, of which 16,5 s was that one method. It grew linearly with the visitor table, and thewithCount(['pageViews', 'events'])in front of it was work thrown away, since the score recounted the page views itself. The metrics now come from three grouped queries inEngagementScores, with the score itself still computed in PHP. The same endpoint is now 22 queries. - Fix Assigning visitors to a segment always failed -
Segment::visitors()andVisitor::segments()both declaredwithTimestamps(), but the pivotkai_personalize_segment_visitorhas onlyassigned_at, nocreated_at/updated_at. Everyattach()therefore sent two columns that do not exist and threwSQLSTATE[42S22]: Unknown column 'created_at' in 'field list'. No segment has ever been able to gain a visitor. Both relations dropwithTimestamps();assigned_atalready records when the link was made - Fix Refreshing a segment issued four queries per visitor -
assignMatchingVisitors()loaded all visitors and then calledgetVisitorAttribute()four times each inside the loop, plus three more per match. On the same production data that is roughly 14.000 queries for one refresh. It now eager-loads the four attributes it needs, walks the table in chunks, and writes the matches with a singlesyncWithoutDetaching() - Fix
{{ kai:track }}minted a throwaway visitor per second -generateTempFingerprint()hashed ip + user agent +time(). The 20 sha256 rows it left in production had zero sessions, page views and events: emptied by a merge whose delete guard only recognisedtemp_. Both the source and the guard are fixed - Fix Every event batch ran a full Statamic entry lookup - the skip pattern
kai-personalize/*never matched the endpoint's real path,!/kai-personalize/track, because Statamic prefixes action routes with!. Same miss as the CSRF rule in 1.2.9. The prefix is now read from config - [changed]
visit_countcounts visits again, not page views - it sat insidecreateOrUpdate(), which runs per page view; it only looked correct because the stale session key made every page view a new row. Repairing that lookup alone would have turned the column into a page-view counter.Visitor::createOrUpdate()is split intoresolve()andregisterVisit(), and the second only fires when a new session starts. Existing values are not comparable with new ones - [changed] Fingerprints are unsigned, padded and prefixed (
fp2_) -simpleHash()rantoString(16)on a signed 32-bit int, so half the values carried a minus sign and lengths ran from 1 to 8. Migration000018converts existing rows; two's complement over 32 bits is bijective, so nothing is lost - verified on production data, 183 rows in, 183 distinct values out. Runphp artisan migrate - [changed] The dashboard opens on today, with a period toggle - today / last 7 days / last 30 days / all time, defaulting to today. Every section honours the window: visitors on
last_visit_at, sessions onstarted_at, page views onviewed_at, events oncreated_at. The statistics cards were restructured to match - the hard-coded "New This Week" and "Rule Matches Today" cards took their period from the code, which would contradict the toggle above them - [changed] The engagement score counts sessions, not
visit_count- that column is an all-time counter and cannot be scoped to a period, and the two disagree for about 5% of visitors: production has visitors withvisit_count1 against 11 sessions, and one with 21 against a single session. Mixing the two made the same data score differently per window, so "all time" could rank lower than "last 30 days" and show a different top-10 for a period covering identical data.Visitor::engagementScore()counts sessions too, so the dashboard and the visitor detail page cannot drift apart. Scores shift for the visitors where the two columns disagree - [changed] Page analytics averages moved into SQL -
getAvgScrollDepth()andgetAvgReadingTime()hydrated every matchingEventto average one JSON key in PHP; they are replaced by a singleAVG(CASE WHEN ...)query ingetPageBehaviour() - [changed] The visitor detail page reuses what it already loaded -
behavioralSummary()read the events relation four separate times and the eager-loadedsessions,pageViewsandattributeswere queried again afterwards. Around 20 queries down to 9, with identical output - [changed] The Cloudflare section of the README was wrong for Laravel 11 and 12 - it told you to set
TRUSTED_PROXIESin.env, which nothing reads. It istrustProxies()inbootstrap/app.php, and it must be an explicit range list: Symfony only honours the forwarded headers whenREMOTE_ADDRmatches, which is what keeps a host that is not behind Cloudflare safe from a spoofedX-Forwarded-For - New
php please kai:cleanup-fingerprintsremoves provisional rows that never became an identity: bot records and emptied merge leftovers. Reports by default and deletes only with--apply. Guards on every category: never a real identity, never a visitor with events, never one in a segment, and never one seen within--min-agedays. Non-bottemp_visitors stay put unless--unidentifiedis passed - those are people whose tracker never fired, and deleting them costs their page views. On the production dump: 2.934 bots and 20 leftovers, with events untouched at 10.810 - New
Visitor::calculateEngagementScore()holds the formula as a static, so the dashboard's aggregate path and the per-visitor path cannot diverge.withEngagementMetrics()feeds it precomputed values; without itengagementScore()behaves exactly as before - New Index migration
000017onvisitors.last_visit_at/.first_visit_at,visitor_sessions.started_atand(ended_at, updated_at), andevents.(event_type, visitor_id)- the columns the period filter now filters and sorts on. Runphp artisan migrateafter upgrading
1.2.12
August 21st, 2026
- Fix The tracking rate limiter never expired its counters, so an IP stayed on 429 forever -
ThrottleTrackingincremented first and set a TTL after:Cache::increment($key)on a key that does not exist yet makes the key itself on a file cache store (FileStore::increment()falls back toput($key, 1, 0), andexpiration(0)means9999999999, not "now"), after whichCache::remember()finds a filled key and sets no TTL at all. The counter then only ever went up. After 120 requests that IP was blocked permanently and every event it sent was dropped, until someone cleared the cache - which reset it rather than fixing it, so the 429 came straight back. A database cache store happened to escape this, becauseDatabaseStore::increment()returnsfalseon a missing key. Both windows now run through Laravel'sRateLimiter, which writes the key with its TTL before incrementing, and whose:timerkey lets a counter without a window reset itself - Fix The rate limit was trivial to sidestep, and could be spent on someone else's behalf -
getClientIp()readX-Forwarded-For,CF-Connecting-IPandX-Real-IPstraight off the request without any trusted-proxy check, so a client could hand itself a fresh bucket per request, or fill the bucket of an IP it does not own. It also disagreed with the IP the tracking itself records:TrackVisitor,BlacklistServiceandTrackingControllerall use$request->ip(). The middleware now does too. Behind a reverse proxy (Cloudflare, a load balancer), configure trusted proxies - see Cloudflare Configuration in the README; without it every visitor arrives on the proxy's IP and shares one bucket - [changed] The limits are configurable and the hourly one is higher -
tracking.rate_limit.per_minute(KAI_TRACKING_RATE_LIMIT_PER_MINUTE, default 120) andtracking.rate_limit.per_hour(KAI_TRACKING_RATE_LIMIT_PER_HOUR, default 1000, up from a hard-coded 500). Set either to0to disable that window. The tracker batches events, so one visit costs 5-15 requests: 500 an hour is tight for an address that carries more than one visitor, such as office NAT or a mobile carrier, while the per-minute window still catches an actual flood - [changed] A 429 now carries
Retry-After, along withX-RateLimit-LimitandX-RateLimit-Remaining. The JSON body is unchanged - [changed] The cache keys were renamed from
kai:tracking:{ip}:minute|hourlytokai-personalize:track:{ip}:minute|hour. This is deliberate: the old counters sit in the cache with an expiry in the year 2286, and reusing the names would inherit them. Runphp artisan cache:clearafter upgrading to clear out those dead entries
1.2.11
November 29th, -0001
- N/A Changelog not available.
1.2.10
August 19th, 2026
- Fix The tracker script blocked page rendering -
{{ kai:track }}wrote a bare<script src>, so the parser stopped until the script had been fetched and run. It now carriesdefer. Tracking does not start any later for it:init()already waits for DOM-ready either way - Fix The tracker was served by PHP instead of the webserver - every visitor paid a full framework boot for an 8 KB static file, and occupied a PHP worker while doing so. Measured on a local machine: ~390 ms to first byte, against ~12 ms for the same kind of file served off disk. The script is now published to
public/vendor/kai-personalize/jsand linked from there. Runphp artisan vendor:publish --tag=kai-personalize-assets --forceafter upgrading; without it the addon falls back to the old route and keeps working, just slowly - [changed] The published tracker URL carries a
?v=version query, so an upgrade reaches returning visitors immediately - [changed] The fallback route no longer sends
immutable- it sat on a URL with no version in it, which left returning visitors on a stale tracker for a day after every upgrade. It is nowpublic, max-age=3600 - [changed] Dropped an unused
Visitorimport fromKaiTrack
1.2.9
August 19th, 2026
- Fix Tracking endpoint returned 419 Page Expired - the tracker POSTs to
/!/kai-personalize/track, which runs in thewebmiddleware group and therefore through CSRF verification, while neithertracker.jsnorsendBeaconsends a token. No tracking event has ever arrived on a site that did not work around this. The route now exempts itself fromValidateCsrfToken, so the host application needs no setup at all - [breaking] The HMAC signature layer is gone -
KAI_TRACKING_SECRET,tracking.signature_secretandtracking.signature_ttlare no longer read, andTrackingSignatureServiceis removed. The layer was never finished on the client side: the controller demandedsignature,timestampandnonce, but no tracker version ever sent them, so a filledKAI_TRACKING_SECRETsilently rejected every event with a 403. It was also a weaker reimplementation of CSRF - no session binding, a 300s TTL, and unusable fromsendBeaconon page unload. Requests are guarded by the origin/referer check and the rate limits instead. RemoveKAI_TRACKING_SECRETfrom your.env- it is a dead key - [breaking]
{{ kai:tracking }}no longer returns signature data - it now returnsurlandenabled.{{ kai:tracking:signature }}is removed - [changed] The README's CSRF instructions are obsolete - earlier versions told you to add a
validateCsrfTokens(except: …)rule tobootstrap/app.php. That rule can be removed. It never worked as written either: the documented patternkai-personalize/trackmisses Statamic's action prefix, so the real path!/kai-personalize/tracknever matched it - [changed] Dropped the
versionfield frompackage.json- it had drifted to 1.2.1 and served no purpose (the package is private and never published), leavingServiceProvider::VERSIONand the git tag as the only version sources.TRACKER_VERSIONintracker.jsstays at 1.2.5; the script itself is unchanged in this release
1.2.8
August 15th, 2026
- Fix Tracking crashed on empty UTM parameters -
?utm_term=(as Google Ads appends) producedColumn 'attribute_value' cannot be null, which aborted the rest of the request's tracking: language, device attributes, geolocation and the page view were all silently lost. Empty and non-string values are now skipped - Fix Attribute writes no longer accept empty values -
Visitor::setVisitorAttribute()rejects null, empty strings and empty arrays, and maps unknown attribute types toexternalso an out-of-enum type (such ascrm) can no longer truncate the column - Fix One failing collector no longer wipes the rest - page views are recorded before attributes, and each collector (campaign, language, agent, geolocation, ActiveCampaign) is isolated so a failure in one is logged without losing the others
- Fix Duplicate
blacklistconfig key - the key was defined twice inconfig/kai-personalize.phpand the second definition silently won, leaving the bot filter off. The default ofblacklist.enabledis nowtrue- setKAI_BLACKLIST_ENABLED=falseto keep the old behaviour, and republish the config withphp artisan vendor:publish --tag=kai-personalize-config --force - New
blacklist.skip_known_bots- skips visitors the user agent parser recognises as a bot, without relying on hand-maintained patterns. The SEO whitelist still takes precedence - [changed] Bot check runs before entry resolution - blacklisted traffic no longer pays for the expensive Statamic entry lookup
- [changed] Derived attributes are no longer stored -
time_of_day,day_of_weekandgoogle_maps_linkare computed on read. The first two were already computed live by the tags, and were being written on every single page view - [changed] Blacklist patterns are cached and
MaxMindService/BlacklistServiceare singletons, removing repeated queries and three.mmdbreader instantiations per request - New
{{ kai:visitor }}now exposeslatitude,longitude,google_maps_link,time_of_dayandday_of_week
1.2.7
June 8th, 2026
- Fix Git tags for marketplace releases - Added v1.2.x tags with proper "v" prefix for Statamic marketplace compatibility
- Fix CHANGELOG format - Updated to use New, Fix, [changed] badges for better marketplace display
1.2.6
November 29th, -0001
- N/A Changelog not available.
1.2.6
June 8th, 2026
- Fix Config deep merge - ServiceProvider now uses
array_replace_recursive()instead ofmergeConfigFrom()so missing nested config keys are always filled with addon defaults
1.2.5
November 29th, -0001
- N/A Changelog not available.
1.2.5
May 5th, 2026
- Fix Removed deprecated ScriptProcessorNode - Removed audio fingerprinting to fix browser deprecation warning
- Fix Fingerprinting now uses Canvas + WebGL only (more reliable, no warnings)
- New Extended screen resolution data - Added devicePixelRatio, orientation, and available screen size to device capabilities tracking
- New Server-side user agent tracking - Full browser user agent string now captured server-side for reliability
- New Tracker version in payload - Each tracking request now includes tracker version for debugging
- New Google Maps link - Added google_maps_link attribute when latitude/longitude is available
1.2.4
November 29th, -0001
- N/A Changelog not available.
1.2.4
May 5th, 2026
- New Blacklist settings to config - Added
blacklist.enabledandblacklist.loggingconfiguration options - New Settings page badges - Added visual indicators for Blacklist and Blacklist Logging features
1.2.3
November 29th, -0001
- N/A Changelog not available.
1.2.3
May 5th, 2026
- Fix PSR-4 autoloading - Renamed
src/database/tosrc/Database/for proper PSR-4 compliance
1.2.2
November 29th, -0001
- N/A Changelog not available.
1.2.2
May 5th, 2026
- Fix BlacklistSeeder autoloading - Moved from
database/seeders/tosrc/Database/Seeders/for proper PSR-4 autoloading - New Added
php artisan kai:seed-blacklistcommand for easy database seeding
1.2.1
November 29th, -0001
- N/A Changelog not available.
1.2.1
May 5th, 2026
- New Config option for tracker.js minification -
KAI_USE_MINIFIED_JSenv var to control minified vs regular tracker - [changed] Updated blacklist CP views to use Statamic form layout conventions
- Fix Fixed BlacklistController to extend Statamic CpController
1.2.0
November 29th, -0001
- N/A Changelog not available.
1.2.0
May 5th, 2026
-
New Bot Blacklist Feature
- Database-driven blacklist management via Control Panel
- Block by bot name (e.g., Semrush, Ahrefs) or user agent pattern
- Whitelist for essential SEO bots (Googlebot, Bingbot, etc.)
- Automatic logging of blocked requests with hit counts
- Pre-seeded with common bots, monitoring tools, and AI scrapers
- Configuration:
KAI_BLACKLIST_ENABLED=false(default off for safety)
-
New Tracker.js Minification
- Automated build system using Terser
- File size reduction: 23KB → 8.7KB (~62% smaller)
- Automatic serving of minified version when available
- Build command:
composer run build-jsornpm run build
-
[changed] Updated README.md with Cloudflare configuration (TRUSTED_PROXIES)
1.1.2
March 22nd, 2026
Small bug fixes and documentation and version updates.
1.1.1
March 20th, 2026
Changed
- Edition Rename: "Free" edition renamed to "Lite" edition
- Updated
Edition::isFree()toEdition::isLite() - Updated composer.json editions array
- Updated translations (en/nl) with "Lite tier" references
- Updated documentation (CLAUDE.md, LICENSE, README)
- Updated
Added
- Separate CHANGELOG.md file (moved from README.md)
1.1.0
March 20th, 2026
Added
-
Core Features
- Visitor tracking with fingerprint identification
- Session management with browse history
- Browser & device detection (mobile/desktop/tablet/bot)
- GeoIP2 location detection (local database, no API calls)
- Campaign parameter tracking (UTM)
- Referrer-based personalization
- Cookie consent support
-
Personalization Engine
- Rule-based content delivery with condition builder
- Dynamic visitor segments with criteria-based assignment
- Antlers tags:
{{ kai:visitor }},{{ kai:condition }},{{ kai:content }},{{ kai:segment }} - Session data management:
{{ kai:session:get }},{{ kai:session:set }}
-
Analytics & Engagement
- Page-level analytics (views, unique visitors, scroll depth, reading time)
- Engagement scoring (0-100) based on visits, page views, reading time, scroll depth
- Behavioral event tracking (scroll depth, clicks, reading time, custom events)
- Top engaged visitors ranking
- Visitor page history with pagination
-
External API Integration
- Built-in providers: Weather, Geolocation, News, Exchange rates
- Custom API connections with flexible authentication
- API caching with configurable TTL
- Test connection functionality
- Rate limiting and error handling
-
ActiveCampaign Integration
- Automatic email campaign visitor tracking
- CRM data sync (contact info, tags, lists, custom fields)
- Cookie-based email identification (multiple encoding formats)
-
Control Panel
- Dashboard with real-time statistics
- Analytics pages with engagement metrics
- Rules management (CRUD with condition builder)
- Visitors management (profiles, sessions, page history)
- Segments management (CRUD with refresh functionality)
- API Connections management (CRUD with testing)
- Settings page with configuration overview
-
Security & Privacy
- HMAC SHA-256 signature validation for tracking endpoints
- Rate limiting (60/minute, 500/hour per IP)
- Timestamp validation for replay attack prevention
- IP encryption and DNT respect
- GDPR compliance features
- Data anonymization and retention controls
-
Developer Features
- Tracker queue with localStorage persistence
- Configurable event threshold and send interval
- Artisan commands for testing and maintenance
- MaxMind database download automation
- Statamic 6 compatible (Vue 3)
-
Localization
- Full English and Dutch support