Analytics: Visitor Events, Conversions & Attribution

The platform tracks visitor behavior in one table, vse_visitor_events, covering both page-view traffic and named conversion events. This doc covers the conventions for recording events and the reporting that consumes them.

Event types

Constants on VisitorEvent (data/visitor_events_class.php):

ConstantValuePurpose
TYPE_PAGE_VIEW1A page view (default for save_visitor_event())
TYPE_COOKIE_CONSENT2Cookie consent acknowledgment
TYPE_CART_ADD3Item added to shopping cart
TYPE_CHECKOUT_START4Visitor reached checkout with cart items
TYPE_PURCHASE5Order completed (payment cleared)
TYPE_SIGNUP6New user account created
TYPE_LIST_SIGNUP7Subscribed to a mailing list (one event per list)
TYPE_COUPON_ATTEMPT8Arrived with ?coupon=CODE URL (diagnostic, not a conversion)

Recording events

Bot filtering

Before any row is inserted, save_visitor_event() short-circuits on SessionControl::crawlerDetect($USER_AGENT). The filter is a case-insensitive substring match against a list of known bot patterns (Googlebot, bingbot, facebookexternalhit, Ahrefs, Semrush, curl, python-requests, etc.), plus any request with an empty UA.

Historical note: The filter was silently reporting every real bot as not a bot for a long time due to a reversed strpos() — so bot traffic was being counted in vse_visitor_events. When the filter was fixed, page-view totals typically drop by 20–40% on small sites as bot traffic stops being recorded. If you compare pre- and post-fix analytics numbers, expect that discontinuity.

The same filter gates A/B test counters — see ab_testing.md.

Recording events

The canonical call is on SessionControl:

$session->save_visitor_event($type, $is_404 = FALSE, $ref_type = NULL, $ref_id = NULL, $meta = NULL);

  • $type — a VisitorEvent::TYPE_* constant
  • $ref_type / $ref_id — a polymorphic reference to the entity the event is about (e.g. 'order' + ord_order_id)
  • $meta — free-form metadata for diagnostic rows (e.g. attempted coupon code for TYPE_COUPON_ATTEMPT)

UTM auto-attribution

save_visitor_event() stamps UTM values onto every event row:

  1. Page views pull UTM from the current request query string; values are also mirrored to $_SESSION['utm_*'] on first touch for later reuse.
  2. Conversion events (non-page-view types) fall back to the session UTM when the request has no query string — so a PURCHASE event fired from a POST handler still carries the original source.
This means conversion counts and revenue can be grouped directly by vse_source without joining back through the event stream.

Conversion hook sites

EventCanonical siteReference columns
CART_ADDShoppingCart::add_item() after the item is pushed
CHECKOUT_STARTviews/cart.php when the checkout form renders, guarded by $_SESSION['checkout_started']
PURCHASElogic/cart_charge_logic.php after STATUS_PAIDref_type='order', ref_id=ord_order_id
SIGNUPUser::CreateCompleteNew() when a genuinely new user is createdref_type='user', ref_id=usr_user_id
LIST_SIGNUPUser::add_user_to_mailing_lists() after each successful subscriptionref_type='mailing_list', ref_id=mlt_mailing_list_id
COUPON_ATTEMPTSessionControl::capture_marketing_coupon() for both valid and invalid codesvse_meta=<code> (never in vse_source)
The $_SESSION['checkout_started'] flag is cleared in two places so a fresh cart cycle gets a fresh CHECKOUT_START:
  • ShoppingCart::clear_cart() — cart emptied
  • cart_charge_logic.php — after the PURCHASE event fires

Attribution reporting

Admin page: Statistics → Attribution (/admin/admin_analytics_attribution)

Filters: date range, optional source filter, optional campaign filter, include-test-orders toggle.

Sections:

  1. Channels overview — grouped by vse_source with visits, signups, list signups, cart-adds, checkouts, purchases, revenue, conversion rate
  2. Time-series chart — daily visits by top-5 sources (Chart.js 2.8.0)
  3. Campaign drilldown — grouped by (source, campaign) to spot which campaign within a channel is producing results

Query conventions

Every Part E query enumerates specific vse_type values — no bare COUNT(*) against vse_visitor_events, no vse_type >= N range filters. The conversion set is:

WHERE vse_type IN (TYPE_CART_ADD, TYPE_CHECKOUT_START, TYPE_PURCHASE,
                   TYPE_SIGNUP, TYPE_LIST_SIGNUP)

Source normalization happens in the query (LOWER(vse_source)) so reddit / Reddit / REDDIT collapse. NULL sources are coalesced to '(direct)'. Test orders are excluded from revenue unless the admin checks "Include test orders".

Attribution model

Implicit last-touch on the event row: the UTM that was in session when the conversion fired. Multi-touch models (first-touch / linear / time-decay / data-driven) are not implemented. The speculative design for those is in specs/FUTURE_attribution_models.md.

Adding a new event type

  1. Add a const TYPE_X = N to VisitorEvent
  2. Wire the call site(s) via SessionControl::save_visitor_event(VisitorEvent::TYPE_X, ...)
  3. If the event is a conversion that should appear in attribution reports, add its column to the Part E channels/campaigns queries (conditional SUM(CASE WHEN vse_type = :type_x THEN 1 ELSE 0 END))
  4. If the event uses a reference entity, document the ref_type string and target table