Kai Personalize Icon

Kai Personalize

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 --all and kai: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 (--all on the cleanup would wipe everything instead of 30 days). kai:cleanup is 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 no KaiConsentCallback was defined, so it fell through to return true: on a site with Cookiebot the tracker ran regardless of what the visitor chose. Cookiebot is now detected through Cookiebot.consent.statistics, with CookiebotOnAccept/OnDecline so 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_required finally reads KAI_COOKIE_CONSENT_REQUIRED - that variable was never wired to an env() call and did nothing at all
  • Fix Nine bots sat on a whitelist that switched tracking on rather than off - BlacklistService::isWhitelistedBot() returned early from shouldBlock() for googlebot, facebookexternalhit, twitterbot and six others, so neither the 23 blacklist patterns nor the skip_known_bots catch-all was ever reached and KAI_SKIP_KNOWN_BOTS=true did 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 the temp_ 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 is shouldTrack() so the two meanings cannot blur again
  • Fix Every page view created a visitor and merged it away again - TrackVisitor wrote temp_<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 against visit_count 1. 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 called Visitor::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 + 3N queries, of which 16,5 s was that one method. It grew linearly with the visitor table, and the withCount(['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 in EngagementScores, 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() and Visitor::segments() both declared withTimestamps(), but the pivot kai_personalize_segment_visitor has only assigned_at, no created_at/updated_at. Every attach() therefore sent two columns that do not exist and threw SQLSTATE[42S22]: Unknown column 'created_at' in 'field list'. No segment has ever been able to gain a visitor. Both relations drop withTimestamps(); assigned_at already records when the link was made
  • Fix Refreshing a segment issued four queries per visitor - assignMatchingVisitors() loaded all visitors and then called getVisitorAttribute() 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 single syncWithoutDetaching()
  • 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 recognised temp_. 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_count counts visits again, not page views - it sat inside createOrUpdate(), 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 into resolve() and registerVisit(), 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() ran toString(16) on a signed 32-bit int, so half the values carried a minus sign and lengths ran from 1 to 8. Migration 000018 converts 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. Run php 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 on started_at, page views on viewed_at, events on created_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 with visit_count 1 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() and getAvgReadingTime() hydrated every matching Event to average one JSON key in PHP; they are replaced by a single AVG(CASE WHEN ...) query in getPageBehaviour()
  • [changed] The visitor detail page reuses what it already loaded - behavioralSummary() read the events relation four separate times and the eager-loaded sessions, pageViews and attributes were 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_PROXIES in .env, which nothing reads. It is trustProxies() in bootstrap/app.php, and it must be an explicit range list: Symfony only honours the forwarded headers when REMOTE_ADDR matches, which is what keeps a host that is not behind Cloudflare safe from a spoofed X-Forwarded-For
  • New php please kai:cleanup-fingerprints removes 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-age days. Non-bot temp_ visitors stay put unless --unidentified is 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 it engagementScore() behaves exactly as before
  • New Index migration 000017 on visitors.last_visit_at / .first_visit_at, visitor_sessions.started_at and (ended_at, updated_at), and events.(event_type, visitor_id) - the columns the period filter now filters and sorts on. Run php artisan migrate after upgrading

1.2.12

August 21st, 2026

  • Fix The tracking rate limiter never expired its counters, so an IP stayed on 429 forever - ThrottleTracking incremented 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 to put($key, 1, 0), and expiration(0) means 9999999999, not "now"), after which Cache::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, because DatabaseStore::increment() returns false on a missing key. Both windows now run through Laravel's RateLimiter, which writes the key with its TTL before incrementing, and whose :timer key 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() read X-Forwarded-For, CF-Connecting-IP and X-Real-IP straight 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, BlacklistService and TrackingController all 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) and tracking.rate_limit.per_hour (KAI_TRACKING_RATE_LIMIT_PER_HOUR, default 1000, up from a hard-coded 500). Set either to 0 to 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 with X-RateLimit-Limit and X-RateLimit-Remaining. The JSON body is unchanged
  • [changed] The cache keys were renamed from kai:tracking:{ip}:minute|hourly to kai-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. Run php artisan cache:clear after 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 carries defer. 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/js and linked from there. Run php artisan vendor:publish --tag=kai-personalize-assets --force after 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 now public, max-age=3600
  • [changed] Dropped an unused Visitor import from KaiTrack

1.2.9

August 19th, 2026

  • Fix Tracking endpoint returned 419 Page Expired - the tracker POSTs to /!/kai-personalize/track, which runs in the web middleware group and therefore through CSRF verification, while neither tracker.js nor sendBeacon sends a token. No tracking event has ever arrived on a site that did not work around this. The route now exempts itself from ValidateCsrfToken, so the host application needs no setup at all
  • [breaking] The HMAC signature layer is gone - KAI_TRACKING_SECRET, tracking.signature_secret and tracking.signature_ttl are no longer read, and TrackingSignatureService is removed. The layer was never finished on the client side: the controller demanded signature, timestamp and nonce, but no tracker version ever sent them, so a filled KAI_TRACKING_SECRET silently rejected every event with a 403. It was also a weaker reimplementation of CSRF - no session binding, a 300s TTL, and unusable from sendBeacon on page unload. Requests are guarded by the origin/referer check and the rate limits instead. Remove KAI_TRACKING_SECRET from your .env - it is a dead key
  • [breaking] {{ kai:tracking }} no longer returns signature data - it now returns url and enabled. {{ kai:tracking:signature }} is removed
  • [changed] The README's CSRF instructions are obsolete - earlier versions told you to add a validateCsrfTokens(except: …) rule to bootstrap/app.php. That rule can be removed. It never worked as written either: the documented pattern kai-personalize/track misses Statamic's action prefix, so the real path !/kai-personalize/track never matched it
  • [changed] Dropped the version field from package.json - it had drifted to 1.2.1 and served no purpose (the package is private and never published), leaving ServiceProvider::VERSION and the git tag as the only version sources. TRACKER_VERSION in tracker.js stays 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) produced Column '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 to external so an out-of-enum type (such as crm) 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 blacklist config key - the key was defined twice in config/kai-personalize.php and the second definition silently won, leaving the bot filter off. The default of blacklist.enabled is now true - set KAI_BLACKLIST_ENABLED=false to keep the old behaviour, and republish the config with php 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_week and google_maps_link are 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 / BlacklistService are singletons, removing repeated queries and three .mmdb reader instantiations per request
  • New {{ kai:visitor }} now exposes latitude, longitude, google_maps_link, time_of_day and day_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 of mergeConfigFrom() 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.enabled and blacklist.logging configuration 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/ to src/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/ to src/Database/Seeders/ for proper PSR-4 autoloading
  • New Added php artisan kai:seed-blacklist command 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_JS env 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-js or npm 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() to Edition::isLite()
    • Updated composer.json editions array
    • Updated translations (en/nl) with "Lite tier" references
    • Updated documentation (CLAUDE.md, LICENSE, README)

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