Overview
Visitor Analytics is a self-hosted, privacy-aware visitor journey tracker. Page views, time-on-page, exit pages, configurable funnel, conversion goals, UTM attribution, bot detection. All data lives in your own database; nothing is sent to a third party.
Survives login — a visitor's pre-signin events and post-signin events share the same visitorId, so funnel analysis works across the auth boundary.
Since 0.8.0 the plugin also ships:
- Cart abandonment — detection, signed recovery links, Slack notification for high-value drops, admin dashboard.
- Product recommendations —
also-viewed,personal, andtrendingendpoints driven by observed co-viewing behaviour. - Site search analytics — top queries, zero-result queries (direct catalogue-gap intel), search-to-cart conversion.
- Journey drawer buffs — rage-click + dead-click hot-spot lists, per-session heuristic
intentlabel. - Drop-in storefront helper — one script tag from
/ees/hulo.jsand every event helper is onwindow.hulo.
Install
Or by hand:
Storefront integration
Option 1: drop-in helper (recommended)
The plugin serves a typed, batching event helper at /ees/hulo.js. One script tag, every event is on window.hulo:
The helper handles:
- Batching events (1.5s coalescing window) and flushing via
sendBeacononpagehide/beforeunload. - Emitting a
pageviewon load automatically. - Auto rage-click detection (≥3 pointerdowns within 500ms and 20px radius fires
rage_click). - Auto dead-click detection (click on non-interactive element with no URL / significant scroll change within 400ms fires
dead_click).
Option 2: typed helpers in your storefront
Recommended for SSR frameworks (Qwik, Next, Remix, SolidStart) so the tracker code ships in your own bundle. Copy this into utils/visitor-tracking.ts:
See the plugin README for the full implementation including the send() batcher and the auto rage/dead-click detectors.
Where to call the helpers
| Helper | Where to call | Feeds |
|---|---|---|
productView(id) | Product detail page mount + on variant change | co-view aggregation, trending, personal recs |
cartSnapshot(cart) | Every cart change (add / remove / qty) | abandoned-cart detection |
search(q, n) | Once search results have rendered | top / no-result / conversion |
checkoutCompleted(code, total) | Order confirmation page | closes any open abandoned-cart row |
Cart-restore route
The abandoned-cart admin dashboard mints a URL of the form https://shop.example.com/cart/restore?t=<token>. Add a route on your storefront that consumes it:
- Read
?t=from the URL. - Call
GET /ees/recover-cart?t=<token>— returns{{ ok: true, items: [...] }}or{{ error }}. - If the visitor already has items in their live cart, don't overwrite — show a friendly "you already have items in your cart" message.
- Otherwise, re-add each
{{ variantId, qty }}via your Vendure order API (typicallyaddItemToOrder(productVariantId, quantity)). - Navigate to
/cart.
The token is signed, time-bounded (default 72h), and non-reusable. If it's expired or already consumed, the endpoint returns {{ error: 'expired-or-invalid' }}.
Cart abandonment
Detects sessions that put items in the cart but never checked out. Turns them into AbandonedCart rows you can send a recovery email against.
Enable it
How the scanner works
A worker-only interval (5 minutes) walks recent cart_snapshot events, grouped by session. For each session:
- If
checkout_completedlanded later — no-op (or promote an existing abandoned row toconverted). - If the last
cart_snapshotis older thanwindowMinutesand no fresh snapshot — open anabandoned_cartrow (unique onsessionId— can't double-open). - If the value clears
slackMinValueMinor, POST a one-off notification to Slack (idempotent —notificationSentflag).
Admin dashboard
Under Analytics → Abandoned carts. Filters by status / min value / email / window; KPIs (open, recovered, converted, lost value); actions per row: mint recovery link (copies URL to clipboard), mark recovered / dismissed manually. CSV export.
Recovery link lifecycle
- Admin clicks "Recovery link" on an abandoned row.
- Backend mints a random opaque token and returns
{{ url: '<storefront>/cart/restore?t=...' }}. - Admin drops the URL into a recovery email.
- Recipient clicks — the storefront's
/cart/restoreroute exchanges the token via/ees/recover-cart?t=...and re-adds the items. - Once the visitor completes the checkout, the scanner promotes the row to
converted.
Product recommendations
Recommendations derived from observed co-viewing behaviour. The scanner walks recent product_view events per session, extracts every ordered pair, and increments a counter per (productIdA, productIdB, channelId) triple.
Endpoints
| Endpoint | Use |
|---|---|
GET /ees/recommendations/also-viewed?productId=42&limit=10 | "Customers who viewed X also viewed…" rail on the product page. |
GET /ees/recommendations/personal?visitorId=abc&limit=10 | Personalised recs based on the visitor's last 10 product views over 30 days. Excludes seeds so the same product never appears on the rail. |
GET /ees/recommendations/trending?hours=24&limit=10 | Most-viewed products in the window. Reflects real intent (not search-console clicks). |
GET /ees/recommendations/aggregate-now | Force a fresh sweep (SuperAdmin only). Useful after a big data backfill. |
Storefront wiring
Site search analytics
Reads back over the visitor_event table where the storefront has fired search custom events. Zero new schema.
Storefront wiring
Endpoints
| Endpoint | Use |
|---|---|
GET /ees/search-analytics/top?days=7 | Top queries by volume with average results count. |
GET /ees/search-analytics/no-results?days=7 | Queries that returned zero hits. Direct catalogue-gap intel. |
GET /ees/search-analytics/conversion?days=7 | Of sessions that searched, what fraction went on to add_to_cart. |
Journey drawer buffs
Rage-click + dead-click hot spots
The auto-detectors bundled with hulo.js fire rage_click / dead_click events with the offending element's CSS selector. Two admin endpoints aggregate them per URL:
GET /ees/journey/rage-clicks?days=7— pages where visitors are frustrated.GET /ees/journey/dead-clicks?days=7— elements that look clickable but aren't.
Both are conservative heuristics — the signal is direction-of-frustration, not a metric to optimise against.
Per-session intent labels
GET /ees/journey/session-summary?visitorId=abc returns one row per session with a heuristic intent label:
| Label | Meaning |
|---|---|
purchase | Fired checkout_completed. Best outcome. |
abandon | Added to cart or fired a cart snapshot but did not check out. |
frustrate | Fired rage_click. Time to look at the URL. |
consider | Viewed ≥5 pages but didn't add to cart. |
browse | Genuine browsing that didn't hit any of the above. |
bounce | Single pageview, gone in <15s. |
Conversion goals
A conversion goal is a URL glob that, when matched by a pageview, counts that visitor as having completed the goal. Patterns support:
*— match zero or more chars within a path segment**— match zero or more segments (including/)- everything else is a literal substring (case-insensitive)
Examples
| Pattern | Matches |
|---|---|
/checkout/thank-you/* | Order confirmation page |
/signup | Exact: signup landing |
**/wishlist | Any wishlist page on any subdomain |
/contact?* | Contact form with any query |
Creating a goal
Once created, every matching pageview is tagged with the goalId on its visitor_event row. The admin stats endpoint at /ees/goals/stats?days=30 aggregates completions per goal.
Privacy controls
The plugin defaults to privacy-respecting behaviour. Toggle as needed:
| Option | Default | Effect |
|---|---|---|
honorDoNotTrack | true | If the visitor's request has DNT: 1 or Sec-GPC: 1, the endpoint returns 200 with skipped: 'dnt' and writes nothing. |
anonymizeIp | true | The stored ip column drops the last octet of IPv4 (or last 80 bits of IPv6). The ipHash column still uses the raw IP so "unique visitor" counts remain accurate. |
requireConsent | false | If on, the endpoint returns skipped: 'no-consent' unless the body sets consent: true or the request has cookie ees_consent=1. |
dropBotEvents | false | If on, known bot UAs are dropped entirely. Default off so bot share is visible on the dashboard. |
Bot detection
Every event is checked against an embedded list of ~45 bot UA patterns: Googlebot, Bingbot, Facebook scrapers, monitoring probes (UptimeRobot, Datadog, Pingdom), HTTP libraries (curl, wget, axios, requests, node-fetch), headless browsers (HeadlessChrome, Puppeteer, Playwright).
By default these events are stored with isBot: true so you can see bot share but they're excluded from "real human" counts in the admin dashboards. Flip dropBotEvents: true to skip ingest entirely.
Admin UI tour
Other admin views:
- Funnel — drop-off per configured step
- Exit pages — where visitors leave
- Top events — custom event distribution
- Top pages — most-visited URLs
- Live — SSE-streamed real-time count
- Journey — full per-visitor timeline (drill from any of the views above)
HTTP endpoints
Public (browser-safe, CORS-permissive)
| Method | Path | Description |
|---|---|---|
| POST | /ees/track | Ingest a batch of visitor events |
| GET | /ees/hulo.js | Typed storefront helper JS (0.8.1) |
| GET | /ees/recover-cart?t=<token> | Resolve a recovery-link token → cart items |
| GET | /ees/recommendations/also-viewed?productId=… | Co-view recommendations for one product |
| GET | /ees/recommendations/personal?visitorId=… | Personalised recs from visitor history |
| GET | /ees/recommendations/trending?hours=… | Most-viewed products in window |
Admin (requires a Vendure admin session; ReadCustomer unless noted)
| Method | Path | Description |
|---|---|---|
| GET | /ees/visitors/summary | Top-line + daily series |
| GET | /ees/visitors/sources | Top sources by visits / sessions |
| GET | /ees/visitors/top-pages | Most-visited URLs |
| GET | /ees/visitors/funnel | Configurable funnel with drop-offs |
| GET | /ees/visitors/exit-pages | Top exit pages |
| GET | /ees/visitors/live | SSE live-now stream |
| GET | /ees/visitors/journey/:visitorId | Per-visitor timeline |
| GET | /ees/visitors/export.csv | CSV export (max 90 days) |
| POST | /ees/goals | Create a conversion goal |
| GET | /ees/goals/stats | Per-goal completion stats |
| GET | /ees/abandoned-carts | 0.8.0 — paginated list with filters |
| GET | /ees/abandoned-carts/summary | 0.8.0 — KPIs + recovery rate |
| GET | /ees/abandoned-carts/:id | 0.8.0 — detail incl. parsed items |
| POST | /ees/abandoned-carts/:id/recovery-link | 0.8.0 — mint signed recovery URL (UpdateCustomer) |
| POST | /ees/abandoned-carts/:id/status | 0.8.0 — mark recovered / dismissed (UpdateCustomer) |
| GET | /ees/abandoned-carts/export.csv | 0.8.0 — CSV export |
| GET | /ees/recommendations/aggregate-now | 0.8.0 — force co-view sweep (SuperAdmin) |
| GET | /ees/search-analytics/top | 0.8.0 — top search queries |
| GET | /ees/search-analytics/no-results | 0.8.0 — zero-result queries |
| GET | /ees/search-analytics/conversion | 0.8.0 — search→cart rate |
| GET | /ees/journey/rage-clicks | 0.8.0 — rage-click hot spots |
| GET | /ees/journey/dead-clicks | 0.8.0 — dead-click hot spots |
| GET | /ees/journey/session-summary?visitorId=… | 0.8.0 — per-session intent labels |
Troubleshooting
Visitors aren't being counted
Check the response of POST /ees/track — if skipped: 'dnt' the visitor is sending a Do-Not-Track header (and your honorDoNotTrack option is on, which it is by default). Set the option to false to override.
Goals don't seem to fire
The matcher only runs for type: 'pageview' events — custom events (type: 'event') don't trigger goals. Also, the goal cache refreshes every 60s; new goals start counting after that.
MaxMind geo isn't populating
The plugin uses the geolite2-redist package to download the GeoLite2 City DB on first use. If the download fails (network, sandboxed env), geo fields stay null. You can force a re-download with npx geolite2-redist refresh from your Vendure project root.