Self-hosted full-funnel visitor analytics for Vendure storefronts. Pageviews, time-on-page, exit pages, configurable funnel, UTM attribution, conversion goals with URL-glob matching, bot detection, and a per-visitor profile drawer with parsed user-agent and MaxMind geo. Privacy-first defaults: DNT, IP anonymisation, optional consent gate.
Since 0.8.0 the plugin also ships cart abandonment (detection,
signed recovery links, Slack notification, admin dashboard),
co-view product recommendations (also-viewed / personal /
trending), site search analytics (top queries, zero-result
queries, search-to-cart conversion) and journey-drawer buffs
(rage-click + dead-click hot-spot lists, per-session intent
labels).
Maintained by Wayne Garrison.
7-day free trial then £9.95/month, or £199 one-off lifetime at elite.charity/licence/buy/vendure-plugin-visitor-analytics.
Add VisitorAnalyticsPlugin.uiExtensions to your compileUiExtensions
config to pick up the Abandoned Carts + Analytics Insights admin pages.
The plugin ships a drop-in JS helper at /ees/hulo.js — one script
tag and every event API below is available on window.hulo. It handles
batching, sendBeacon on unload, auto rage-click + dead-click
detection, and an on-mount pageview. Bare minimum:
For a first-party integration (recommended — one bundle instead of a
second script tag), copy the equivalent typed helpers into your
storefront. The elite.charity Qwik storefront
uses this pattern. Every helper below is a thin wrapper around
POST /ees/track with a specific meta.eventType — the plugin's
server-side scanners look those event types up by name.
| Helper | When to call | What it feeds |
|---|---|---|
hulo.pageview() | first mount + every route change | pageview funnel, exit-page report |
hulo.productView(productId, variantId?) | on the PDP | co-view aggregation, also-viewed, trending, personal recs |
hulo.addToCart(variantId, qty, unitPriceMinor) | on the "add" button | search-to-cart conversion |
hulo.cartSnapshot({ currency, totalMinor, itemCount, items, email? }) | every cart change (add / remove / qty) | cart abandonment detection |
hulo.search(query, resultsCount) | on every executed search | top-queries, zero-result queries |
hulo.checkoutCompleted(orderCode, totalMinor) | on the thank-you page | closes any open abandoned_cart row for this session |
hulo.rageClick(selector) / hulo.deadClick(selector) | fire yourself if you have a better signal than the auto-detector | rage-click / dead-click hot-spot lists |
hulo.restoreCart(token) | on your /cart/restore?t=... route | rebuild a cart from a signed recovery link |
Full payload shapes:
The recovery link the admin mints (see below) lands on
https://shop.example.com/cart/restore?t=<token>. Your storefront
needs a route that:
?t= from the URLGET /ees/recover-cart?t=<token> to fetch { items: [...] }{ variantId, qty } via your Vendure order API (usually
addItemToOrder(productVariantId, quantity))/cart when doneGuard against silently overwriting a live cart — if the visitor
already has items, show a "you already have items in your cart"
message and let them reconcile. See
elite.charity's src/routes/cart/restore/index.tsx
for a working reference implementation.
If you prefer to skip /ees/hulo.js, the raw POST shape is unchanged:
POST /ees/track accepts a batch of up to 50 events at once.ees_vid, ees_sid) issued + refreshed
automatically. When signingSecret is set, cookies are HMAC-signed
and tampered values are rejected — the visitor gets a fresh id.Secure flag is set automatically when serving over HTTPS.Per event:
ua-parser-js → browser, version, OS,
device.geolite2-redist). Skipped when the upstream
proxy already provides a country (Cloudflare, Akamai, Fastly).utmSource, utmMedium, utmCampaign, utmTerm, utmContent. Plus
referrerDomain for grouping by source even when UTM is absent.isBot=true.A goal is a URL glob that, when matched, counts the visitor as having
converted. Supports * (within segment) and ** (across segments).
Stats at GET /ees/goals/stats?days=30&channelId=1.
honorDoNotTrack: true (default) — DNT: 1 and Sec-GPC: 1 requests
get a 200 with {stored:0, skipped:'dnt'}.anonymizeIp: true (default) — IPv4 last octet dropped before
storage; IPv6 reduced to the first 3 hextets. ipHash still uses the
raw IP so unique-visitor counts stay accurate.requireConsent: false (default) — flip on to require a consent: true
body field or an ees_consent=1 cookie before ingest.dropBotEvents: false (default) — flip on to skip bot UAs entirely.Detects sessions that got as far as putting items in the cart but
never fired checkout_completed. Turns them into AbandonedCart rows
you can send a recovery email against.
How detection works.
The plugin runs a worker-only sweep every 5 minutes. It looks at every
session that fired at least one cart_snapshot event, and:
checkout_completed landed later — do nothing (or if an
abandoned_cart row already exists, promote it to converted).cart_snapshot is older than abandonment.windowMinutes
(default 30) — open an abandoned_cart row, keyed on sessionId
(unique — you can't double-open the same session).Recovery link.
POST /ees/abandoned-carts/:id/recovery-link mints a signed opaque
token and returns { ok: true, url: '<storefront>/cart/restore?t=...' }.
The token is time-bounded (recoveryLinkTtlHours, default 72) and
non-reusable. The storefront exchanges it via
GET /ees/recover-cart?t=<token> to get back the persisted item list.
Set abandonment.recoveryLinkSecret in plugin options to enable this —
without it, the endpoint returns { error: 'recovery-disabled-or-not-found' }.
Slack notification.
abandonment.slackWebhookUrl + abandonment.slackMinValueMinor
control an at-most-once Slack post per abandonment above the value
threshold. Useful for sales teams that follow up on high-value drops
manually.
Admin dashboard. Under Analytics → Abandoned carts. Filters by status / min value / email / window. Actions per row: mint recovery link (copies URL to clipboard), mark recovered manually, dismiss. CSV export.
A ProductCoView aggregate table holds a per-triple counter
(productIdA, productIdB, channelId) → viewsTogether. Rebuilt every 6
hours from the last 24h of product_view events, bounded to 20 events
per session so runaway bot sessions can't skew the table.
Denormalised — we store both (A, B) and (B, A) — so read-side
lookups are one indexed scan.
Three endpoints, all safe from the storefront (no PII):
| Endpoint | Use |
|---|---|
GET /ees/recommendations/also-viewed?productId=42&limit=10 | product-page rail: "customers who viewed X also viewed…" |
GET /ees/recommendations/personal?visitorId=abc&limit=10 | homepage / cart recs for a returning visitor. Uses their last 10 product_view events over 30 days, excludes the seeds so the same product never appears |
GET /ees/recommendations/trending?hours=24&limit=10 | homepage rail: most-viewed products in the window. Reflects real intent (not search-console clicks) |
GET /ees/recommendations/aggregate-now (SuperAdmin only) forces a
sweep — useful after a big backfill or spike.
Zero-schema-cost queries over the existing visitor_event table where
the storefront has fired hulo.search(query, resultsCount) events.
| 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 |
| Endpoint | Use |
|---|---|
GET /ees/journey/rage-clicks?days=7 | rage-click hot-spot list per URL. Pages where visitors are frustrated |
GET /ees/journey/dead-clicks?days=7 | dead-click hot-spot list per URL. Elements that LOOK clickable but aren't |
GET /ees/journey/session-summary?visitorId=abc | per-session summary with a heuristic intent label (purchase / abandon / frustrate / consider / browse / bounce) |
Rage-click auto-detector fires on ≥3 pointerdown events within 500ms and a 20-pixel radius. Dead-click auto-detector fires when a click lands on a non-interactive element and no navigation / significant scroll follows within 400ms. Both are conservative heuristics — the signal is direction-of-frustration, not a metric to optimise against.
SSE stream at GET /ees/visitors/live pushes the active-visitor count
and the 20 most recent URLs every 5 seconds. Auto-reconnects.
Click any visitor for the full timeline: pages, custom events, time-on-page, country, browser, OS.
GET /ees/visitors/export.csv?days=N (max 90 days) returns the raw
events with full enrichment.
Public (no auth — CORS-permissive for browser calls from any storefront origin):
| Method | Path | Purpose |
|---|---|---|
POST | /ees/track | ingest a batch of visitor events |
GET | /ees/hulo.js | typed storefront helper JS (since 0.8.1) |
GET | /ees/recover-cart?t=<token> | resolve a recovery token → items |
GET | /ees/recommendations/also-viewed?productId=… | co-view recs |
GET | /ees/recommendations/personal?visitorId=… | personalised recs |
GET | /ees/recommendations/trending?hours=… | most-viewed products |
Admin (Vendure ReadCustomer unless noted; requires a
Vendure admin session cookie):
| Method | Path | Purpose |
|---|---|---|
GET | /ees/visitors/summary | top-line + daily series |
GET | /ees/visitors/sources | top sources by visits |
GET | /ees/visitors/top-pages | most-visited URLs |
GET | /ees/visitors/funnel | configurable funnel |
GET | /ees/visitors/exit-pages | top exit pages |
GET | /ees/visitors/top-events | top custom events |
GET | /ees/visitors/live | SSE live-now stream |
GET | /ees/visitors/journey/:visitorId | per-visitor timeline |
GET | /ees/visitors/recent | recent events |
GET | /ees/visitors/export.csv | CSV export |
GET | /ees/goals | list conversion goals |
POST | /ees/goals | create a goal |
PUT | /ees/goals/:id | update a goal |
DELETE | /ees/goals/:id | delete a goal |
GET | /ees/goals/stats | per-goal completion stats |
GET | /ees/visitors/status | version + update status |
GET | /ees/abandoned-carts | paginated list w/ filters (0.8.0) |
GET | /ees/abandoned-carts/summary | totals + recovery rate (0.8.0) |
GET | /ees/abandoned-carts/:id | detail incl. parsed items (0.8.0) |
POST | /ees/abandoned-carts/:id/recovery-link | mint signed URL (0.8.0, UpdateCustomer) |
POST | /ees/abandoned-carts/:id/status | mark recovered/dismissed (0.8.0, UpdateCustomer) |
GET | /ees/abandoned-carts/export.csv | CSV export (0.8.0) |
GET | /ees/recommendations/aggregate-now | force co-view sweep (0.8.0, SuperAdmin) |
GET | /ees/search-analytics/top | top queries (0.8.0) |
GET | /ees/search-analytics/no-results | zero-result queries (0.8.0) |
GET | /ees/search-analytics/conversion | search→cart rate (0.8.0) |
GET | /ees/journey/rage-clicks | rage-click hot spots (0.8.0) |
GET | /ees/journey/dead-clicks | dead-click hot spots (0.8.0) |
GET | /ees/journey/session-summary?visitorId=… | per-session intent labels (0.8.0) |
User manual + screenshots: huloglobal.com/vendure-plugins/visitor-analytics/docs/
Re-send every active key on file at elite.charity/licence/forgot.
Commercial. Buy at elite.charity/licence/buy/vendure-plugin-visitor-analytics.
All notable changes to @huloglobal/vendure-plugin-visitor-analytics are
documented here. The format follows
Keep a Changelog and this project
adheres to semantic versioning.
anonymizeIp produced a malformed address (e.g. fe80::1::) for
already-abbreviated IPv6 inputs. Abbreviated addresses are now left
unchanged; full-form IPv6 still truncates to the first three hextets.GET /ees/abandoned-carts now includes each cart's parsed
items array plus a short server-rendered itemsPreview
string ("Windows 10 Pro, Office 2021 Pro +2 more"), sorted by
quantity descending. Admin can see WHAT was abandoned without
clicking through to the detail endpoint.GET /ees/abandoned-carts/:id fills in missing name (and
productId when it can) on every item by looking up
product_variant_translation / product_translation. Only
fills where the storefront snapshot didn't already capture a
name — a stored name at cart time is more accurate than a live
catalog lookup (products can be renamed after abandonment).itemsJson string from its
response — clients that need the JSON blob can hit the detail
endpoint. Response payload stays lean.trending, also-viewed, personal)
now enrich every row with productName and productSlug via a
single bulk lookup against product_translation. English
translation preferred for multi-locale stores; single-locale
installs land on their only translation. Soft-deleted products
are excluded.RecommendedProduct.{ productId, productName, productSlug, score, views } — the
addition is backwards-compatible for consumers that only read
productId, but callers that render the row can now show the
name without a second round-trip. Admin UI updated in the
parallel ee.software commit to render "Windows Server 2022
Datacenter #42" instead of just "#42"./ees/hulo.js storefront helper, cart-abandonment configuration
and lifecycle, recommendation endpoints, search analytics,
journey drawer buffs. Includes a walkthrough of the
storefront-side /cart/restore route customers implement to
consume recovery links./ees/hulo.js — the plugin now serves a drop-in typed storefront
helper at this path. One <script src> tag and every event API
(hulo.cartSnapshot, hulo.productView, hulo.search,
hulo.checkoutCompleted, hulo.restoreCart) is available on
window.hulo. Handles batching, sendBeacon on unload, and
installs auto rage-click + dead-click detectors. Served with a
10-minute browser TTL + 24-hour stale-while-revalidate and
permissive CORS so it works cross-origin from any storefront.Cart abandonment — end-to-end.
AbandonedCart entity, keyed on session id. One row per
abandoned session, refreshed in place until it either converts
(order placed) or expires (recovery window elapses).AbandonedCartService.scan() — periodic sweep finds sessions with
cart_snapshot events but no checkout_completed in the
abandonment window (default 30 min). Auto-promotes previously
abandoned rows to converted when the customer later checks out.POST /ees/abandoned-carts/:id/recovery-link
returns a time-bounded opaque token the storefront exchanges via
GET /ees/recover-cart?t=… to rebuild the exact cart. Storefront
never sees the underlying items until the token is presented.abandonment plugin option.GET /ees/abandoned-carts — paginated list with status,
value and email filters.GET /ees/abandoned-carts/summary — totals, recovery rate,
recovered vs. lost value in the window.GET /ees/abandoned-carts/:id — detail incl. parsed items.POST /ees/abandoned-carts/:id/status — mark recovered /
dismissed / re-open.GET /ees/abandoned-carts/export.csv — CSV export.Co-view product recommendations.
ProductCoView aggregate table. Scanner walks recent
product_view events per session, extracts every ordered pair,
and increments a per-triple counter. Bounded to 20 events per
session so runaway bot sessions can't skew the table.(A, B) and (B, A) stored — so read-side
lookups are one indexed scan.GET /ees/recommendations/aggregate-now for admins to kick a
fresh run after a data backfill.GET /ees/recommendations/also-viewed?productId=… — the
"customers who viewed X also viewed…" rail on a product page.GET /ees/recommendations/personal?visitorId=… — personalised
recs for a returning visitor, from their last 10 product views
over 30 days. Excludes seeds.GET /ees/recommendations/trending?hours=24 — most-viewed
products in the window. Reflects real interest, not search-
console clicks.Site search analytics.
visitor_event where the storefront has fired
hulo.search(query, resultsCount). Zero new schema.GET /ees/search-analytics/top — top queries by volume with
average results count.GET /ees/search-analytics/no-results — top zero-result queries.
Direct catalogue-gap intel.GET /ees/search-analytics/conversion — of sessions that
searched, what fraction went on to fire add_to_cart.Journey drawer buffs.
intent label
(purchase / abandon / frustrate / consider / browse /
bounce) computed from event history — one glance per session
in the Journey drawer instead of scrolling event rows.Storefront helper events (documented in the README).
hulo.cartSnapshot({ currency, totalMinor, itemCount, items, email })hulo.productView(productId, productVariantId?)hulo.search(query, resultsCount)hulo.rageClick(url, selector?) / hulo.deadClick(url, selector)hulo.checkoutCompleted()POST /ees/track endpoint with a
standard shape, so admins can also fire them from any language.checkout_completed is now a first-class recognised event type
— the abandonment scanner uses it to auto-close matched rows.warnIfIncompatibleVendure(). Logs a non-fatal warning when the runtime
@vendure/core version is outside the tested range. Silent when inside;
fail-open on unparseable versions.@vendure/core tightened to >=3.5.0 <4.0.0 — Vendure 3.5,
3.6 and 3.7 are all covered. Anything under 3.5 has never been tested;
anything from 4.0 upwards is deferred until the changelog is reviewed.@huloglobal/vendure-licence-sdk@^0.6.0.huloVisitorSummary, huloVisitorSources,
huloVisitorTopPages, huloVisitorFunnel, huloVisitorJourney.POST /ees/track) stays REST — it's anonymous,
high-frequency, and can ingest millions of events a day; the
resolver stack would add pointless overhead per event.isLicensed()
helper. Unlicensed installs get:
POST /ees/track (returns
{skipped: 'free-tier-cap'} after that);display: block on mobile tables that broke
the row / cell alignment.signValue /
verifySignedValue helpers — tampered cookies are rejected.Secure cookie flag is set automatically when serving over HTTPS.POST /ees/track.corsAllowedOrigins option restricts CORS reflection to the
configured list (legacy wildcard preserved when empty).options.retention.@huloglobal/vendure-licence-sdk@^0.2.0.UpdateChecker integration — /ees/visitors/status endpoint returns
version + update info; admin banner appears on new releases.ConversionGoal entity with a URL-glob
matcher (* within segment, ** across segments). Pageviews matching
a goal are tagged with goalId at ingest. CRUD endpoints
(GET /ees/goals, POST /ees/goals, PUT /ees/goals/:id,
DELETE /ees/goals/:id) and GET /ees/goals/stats for completion
totals per period.isBot boolean on every event.
Default keeps bot events for visibility; new dropBotEvents option
skips ingest entirely.honorDoNotTrack (default true) — DNT: 1 returns
{stored:0, skipped:'dnt'}anonymizeIp (default true) — IPv4 last octet / IPv6 last 80 bits
dropped before storage; ipHash still uses the raw IPrequireConsent (default false) — gate ingest behind a body
consent:true or cookie ees_consent=1GET /ees/visitors/export.csv?days=N (max 90).utmSource / utmMedium / utmCampaign /
utmTerm / utmContent and referrerDomain columns parsed from every
incoming pageview URL. New GET /ees/visitors/sources admin endpoint
groups visitors by (source, medium) plus per-source conversion
counts (reached product page, reached cart/checkout).GET /ees/visitors/live pushing the active-visitor count and the
20 most recent URLs every 5 seconds. SSE clients auto-reconnect.GET /ees/visitors/top-events,
paginated.VisitorAnalyticsPlugin — ingest endpoint + admin dashboards.VisitorEvent entity capturing pageview / unload / event rows with
full UA parse, MaxMind geo enrichment, raw + hashed IP.@huloglobal/vendure-licence-sdk with
revocation polling.