HG Hulo Global

Visitor Analytics — User Manual

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 recommendationsalso-viewed, personal, and trending endpoints 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 intent label.
  • Drop-in storefront helper — one script tag from /ees/hulo.js and every event helper is on window.hulo.

Install

curl -sSL https://huloglobal.com/vendure-plugins/visitor-analytics/install.sh | bash

Or by hand:

# 1. Install yarn add @huloglobal/vendure-plugin-visitor-analytics # 2. Register in vendure-config.ts import {{ VisitorAnalyticsPlugin }} from '@huloglobal/vendure-plugin-visitor-analytics'; export const config: VendureConfig = {{ plugins: [ VisitorAnalyticsPlugin.init({{ publicBaseUrl: 'https://shop.example.com', licenceKey: process.env.HULO_LICENCE_KEY_VISITOR_ANALYTICS, // Privacy options — these are the defaults honorDoNotTrack: true, anonymizeIp: true, requireConsent: false, dropBotEvents: false, }}), ], }}; # 3. Migration yarn migration:generate AddVisitorAnalyticsTables yarn migration:run

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:

<script src="https://shop.example.com/ees/hulo.js" defer></script>

The helper handles:

  • Batching events (1.5s coalescing window) and flushing via sendBeacon on pagehide / beforeunload.
  • Emitting a pageview on 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:

export function recordProductView(productId: number, variantId?: number) {{ send([{{ type: 'event', url: location.href, meta: {{ eventType: 'product_view', productId, productVariantId: variantId, }} }}]); }} export function recordCartSnapshot(cart: {{ currency, totalMinor, items, email? }}) {{ send([{{ type: 'event', url: location.href, meta: {{ eventType: 'cart_snapshot', ...cart, }} }}]); }} export function recordSearch(query: string, resultsCount: number) {{ send([{{ type: 'event', url: location.href, meta: {{ eventType: 'search', query, resultsCount, }} }}]); }} export function recordCheckoutCompleted(orderCode: string, totalMinor: number) {{ send([{{ type: 'event', url: location.href, meta: {{ eventType: 'checkout_completed', orderCode, totalMinor, }} }}], /* useBeacon */ true); }}

See the plugin README for the full implementation including the send() batcher and the auto rage/dead-click detectors.

Where to call the helpers

HelperWhere to callFeeds
productView(id)Product detail page mount + on variant changeco-view aggregation, trending, personal recs
cartSnapshot(cart)Every cart change (add / remove / qty)abandoned-cart detection
search(q, n)Once search results have renderedtop / no-result / conversion
checkoutCompleted(code, total)Order confirmation pagecloses 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:

  1. Read ?t= from the URL.
  2. Call GET /ees/recover-cart?t=<token> — returns {{ ok: true, items: [...] }} or {{ error }}.
  3. If the visitor already has items in their live cart, don't overwrite — show a friendly "you already have items in your cart" message.
  4. Otherwise, re-add each {{ variantId, qty }} via your Vendure order API (typically addItemToOrder(productVariantId, quantity)).
  5. 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

VisitorAnalyticsPlugin.init({{ // ... abandonment: {{ windowMinutes: 30, // how long since last cart_snapshot before we mark as abandoned slackMinValueMinor: 5000, // £50 / $50 threshold for a Slack ping slackWebhookUrl: process.env.HULO_ABANDONMENT_SLACK_URL, recoveryLinkSecret: process.env.HULO_ABANDONMENT_SECRET, recoveryLinkTtlHours: 72, storefrontBaseUrl: 'https://shop.example.com', }}, }})

How the scanner works

A worker-only interval (5 minutes) walks recent cart_snapshot events, grouped by session. For each session:

  • If checkout_completed landed later — no-op (or promote an existing abandoned row to converted).
  • If the last cart_snapshot is older than windowMinutes and no fresh snapshot — open an abandoned_cart row (unique on sessionId — can't double-open).
  • If the value clears slackMinValueMinor, POST a one-off notification to Slack (idempotent — notificationSent flag).

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

  1. Admin clicks "Recovery link" on an abandoned row.
  2. Backend mints a random opaque token and returns {{ url: '<storefront>/cart/restore?t=...' }}.
  3. Admin drops the URL into a recovery email.
  4. Recipient clicks — the storefront's /cart/restore route exchanges the token via /ees/recover-cart?t=... and re-adds the items.
  5. 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

EndpointUse
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=10Personalised 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=10Most-viewed products in the window. Reflects real intent (not search-console clicks).
GET /ees/recommendations/aggregate-nowForce a fresh sweep (SuperAdmin only). Useful after a big data backfill.

Storefront wiring

// on the product detail page hulo.productView(product.id, selectedVariant.id); // then fetch the recs rail const res = await fetch(`/ees/recommendations/also-viewed?productId=${{product.id}}&limit=8`); const {{ items }} = await res.json(); // items: [{{ productId, score }}] // Hydrate names / images from your usual Vendure product fetch.

Site search analytics

Reads back over the visitor_event table where the storefront has fired search custom events. Zero new schema.

Storefront wiring

// once results have rendered hulo.search(query, results.totalItems);

Endpoints

EndpointUse
GET /ees/search-analytics/top?days=7Top queries by volume with average results count.
GET /ees/search-analytics/no-results?days=7Queries that returned zero hits. Direct catalogue-gap intel.
GET /ees/search-analytics/conversion?days=7Of 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:

LabelMeaning
purchaseFired checkout_completed. Best outcome.
abandonAdded to cart or fired a cart snapshot but did not check out.
frustrateFired rage_click. Time to look at the URL.
considerViewed ≥5 pages but didn't add to cart.
browseGenuine browsing that didn't hit any of the above.
bounceSingle 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

PatternMatches
/checkout/thank-you/*Order confirmation page
/signupExact: signup landing
**/wishlistAny wishlist page on any subdomain
/contact?*Contact form with any query

Creating a goal

curl -X POST https://shop.example.com/ees/goals \ -H "Content-Type: application/json" \ -d '{{ "channelId": 1, "name": "Checkout completed", "urlPattern": "/checkout/thank-you/*", "valueMinor": 5000, "enabled": true }}'

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:

OptionDefaultEffect
honorDoNotTracktrueIf the visitor's request has DNT: 1 or Sec-GPC: 1, the endpoint returns 200 with skipped: 'dnt' and writes nothing.
anonymizeIptrueThe 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.
requireConsentfalseIf on, the endpoint returns skipped: 'no-consent' unless the body sets consent: true or the request has cookie ees_consent=1.
dropBotEventsfalseIf 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

Visitor Journey — Summary Admin panel mockup. Last 30 days — 42,135 visitors / 89,210 sessions / 312,447 pageviews / avg 2:18 on page — Daily series — ▁▂▄▅▇▆▇█▇▆▅▄▃▄▅▆▇▆▅▄▃▂▃▄▅▆▇█▇▆ — Top sources — #1 google.com 18,402 visits — #2 direct 12,089 visits — #3 twitter.com 3,541 visits — #4 facebook.com 2,170 visits Vendure Admin Visitor Journey — Summary Last 30 days42,135 visitors 89,210 sessions 312,447 pageviews avg 2:18 on page Daily series▁▂▄▅▇▆▇█▇▆▅▄▃▄▅▆▇▆▅▄▃▂▃▄▅▆▇█▇▆ Top sources#1 google.com 18,402 visits#2 direct 12,089 visits#3 twitter.com 3,541 visits#4 facebook.com 2,170 visits
Summary — top-line counters, daily series, top sources, top countries.

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)

MethodPathDescription
POST/ees/trackIngest a batch of visitor events
GET/ees/hulo.jsTyped 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)

MethodPathDescription
GET/ees/visitors/summaryTop-line + daily series
GET/ees/visitors/sourcesTop sources by visits / sessions
GET/ees/visitors/top-pagesMost-visited URLs
GET/ees/visitors/funnelConfigurable funnel with drop-offs
GET/ees/visitors/exit-pagesTop exit pages
GET/ees/visitors/liveSSE live-now stream
GET/ees/visitors/journey/:visitorIdPer-visitor timeline
GET/ees/visitors/export.csvCSV export (max 90 days)
POST/ees/goalsCreate a conversion goal
GET/ees/goals/statsPer-goal completion stats
GET/ees/abandoned-carts0.8.0 — paginated list with filters
GET/ees/abandoned-carts/summary0.8.0 — KPIs + recovery rate
GET/ees/abandoned-carts/:id0.8.0 — detail incl. parsed items
POST/ees/abandoned-carts/:id/recovery-link0.8.0 — mint signed recovery URL (UpdateCustomer)
POST/ees/abandoned-carts/:id/status0.8.0 — mark recovered / dismissed (UpdateCustomer)
GET/ees/abandoned-carts/export.csv0.8.0 — CSV export
GET/ees/recommendations/aggregate-now0.8.0 — force co-view sweep (SuperAdmin)
GET/ees/search-analytics/top0.8.0 — top search queries
GET/ees/search-analytics/no-results0.8.0 — zero-result queries
GET/ees/search-analytics/conversion0.8.0 — search→cart rate
GET/ees/journey/rage-clicks0.8.0 — rage-click hot spots
GET/ees/journey/dead-clicks0.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.