Documentation
Badgerlytics Docs
Everything you need to install, configure, and get the most out of Badgerlytics. Use the sidebar to jump around — it highlights where you are as you scroll.
What is Badgerlytics
Badgerlytics is an analytics and experimentation platform built for ecommerce and SaaS teams. We help you understand how visitors use your site, what drives revenue, and which changes actually move the needle — without juggling five different tools.
It bundles three things that usually live in three separate products:
- Analytics: traffic, engagement, revenue, MRR, subscription lifecycle, cart abandonment, and page performance.
- Experimentation: A/B tests and feature flags that plug directly into your revenue and analytics data.
- AI assistance: daily AI insights, a chat analyst that answers questions in plain English, and a UX analyst that reviews your site and recommends improvements.
You bring your site; Badgerlytics brings the tracking script, the reports, the SDKs, and the dashboards. Add the script tag (or one of our framework SDKs), tell us what your funnel looks like, and you're live in under an hour. Use window.badgerlytics from anywhere in your client code to track product views, cart updates, conversions, or any custom event you care about.
Script Installation
Installing Badgerlytics is the same shape everywhere: configure your property ID, load the tracking script, and you're tracking. Pick the guide below that matches your stack. For SSR experiment assignment, see SSR middleware (SDK) — full API and examples for each framework.
Static site / general installation
Works for any HTML page, static site generator, or platform that lets you drop a <script> tag into the page.
<!-- In your <head> or just before </body> --><script>window.badgerlyticsConfig = {propertyId: 'your-property-id',};</script><scriptsrc="https://cdn.badgerlytics.com/scripts/badgerlytics.min.js"async></script>
Where to find your property ID
React (client-side)
For SPAs and other client-rendered React apps. Install the SDK and wrap your app in BadgerlyticsProvider with a config object — the provider sets window.badgerlyticsConfig and loads the tracking script for you. Hooks like useVariation() work once the tracker is ready. For SSR (Next.js, Remix, Nuxt, Astro), use middleware for server-side flags and keep the provider (or HTML snippet) for page views — see SSR middleware (SDK).
npm install badgerlytics-sdk
import { BadgerlyticsProvider, useVariation } from 'badgerlytics-sdk/react';export default function App() {return (<BadgerlyticsProviderconfig={{ propertyId: 'your-property-id' }}><Home /></BadgerlyticsProvider>);}function Home() {const heroVariation = useVariation('hero_test', 'control');return heroVariation === 'v1' ? <HeroB /> : <HeroA />;}
Already using the HTML snippet?
autoLoad={false} and skip config so the provider only bridges React to an existing script tag.<!-- Optional: load via HTML instead of the provider --><script>window.badgerlyticsConfig = { propertyId: 'your-property-id' };</script><scriptsrc="https://cdn.badgerlytics.com/scripts/badgerlytics.min.js"async></script><!-- Then use <BadgerlyticsProvider autoLoad={false}> so hooks wait for the script -->
Next.js (server-side)
Quick start — full middleware API (traits, manual assignment, all read helpers) is in SSR middleware (SDK) → Next.js.
npm install badgerlytics-sdk
// middleware.js (project root)import { createBadgerlyticsMiddleware } from 'badgerlytics-sdk/nextjs';export const middleware = createBadgerlyticsMiddleware({propertyId: 'your-property-id',});export const config = {matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],};
import { headers } from 'next/headers';import {getVariation,getAssignments,getTraits,isFlagEnabled,} from 'badgerlytics-sdk/nextjs';export default async function Page() {const h = await headers();const hero = getVariation(h, 'hero_test', 'control');const traits = getTraits(h);const pricingOn = isFlagEnabled(h, 'new_pricing_table');return hero === 'v1' ? <HeroB traits={traits} /> : <HeroA />;}
Note
Remix / React Router
Quick start — see SSR middleware (SDK) → Remix / React Router for the complete API.
npm install badgerlytics-sdk
// app/middleware.tsimport { createBadgerlyticsMiddleware } from 'badgerlytics-sdk/remix';export const middleware = [createBadgerlyticsMiddleware({ propertyId: 'your-property-id' }),];
import {getVariation,getAssignments,getVisitorId,getTraits,isFlagEnabled,isVariation,} from 'badgerlytics-sdk/remix';export async function loader({ request }) {return {hero: getVariation(request, 'hero_test', 'control'),allFlags: getAssignments(request),visitorId: getVisitorId(request),traits: getTraits(request),isHeroV1: isVariation(request, 'hero_test', 'v1'),pricingOn: isFlagEnabled(request, 'new_pricing_table'),};}
Astro
Quick start — use getVariationFromLocals, not getVariation(request). Full API in SSR middleware (SDK) → Astro.
npm install badgerlytics-sdk
// src/middleware.jsimport { defineMiddleware } from 'astro:middleware';import { createBadgerlyticsMiddleware } from 'badgerlytics-sdk/astro';export const onRequest = defineMiddleware(createBadgerlyticsMiddleware({ propertyId: 'your-property-id' }));
---import {getVariationFromLocals,getAssignmentsFromLocals,getVisitorIdFromLocals,getTraitsFromLocals,isFlagEnabledFromLocals,} from 'badgerlytics-sdk/astro';const hero = getVariationFromLocals(Astro.locals, 'hero_test', 'control');const traits = getTraitsFromLocals(Astro.locals);const pricingOn = isFlagEnabledFromLocals(Astro.locals, 'new_pricing_table');---{hero === 'v1' ? <HeroB traits={traits} /> : <HeroA />}
Nuxt
Quick start — use getVariationFromEvent in server routes. Full API in SSR middleware (SDK) → Nuxt.
npm install badgerlytics-sdk
// server/middleware/badgerlytics.tsimport { createBadgerlyticsMiddleware } from 'badgerlytics-sdk/nuxt';export default createBadgerlyticsMiddleware({propertyId: 'your-property-id',});
import {getVariationFromEvent,getAssignmentsFromEvent,getVisitorIdFromEvent,getTraitsFromEvent,isFlagEnabledFromEvent,} from 'badgerlytics-sdk/nuxt';export default defineEventHandler((event) => {return {hero: getVariationFromEvent(event, 'hero_test', 'control'),allFlags: getAssignmentsFromEvent(event),visitorId: getVisitorIdFromEvent(event),traits: getTraitsFromEvent(event),pricingOn: isFlagEnabledFromEvent(event, 'new_pricing_table'),};});
SSR vs. client rendering considerations
Both work — they just hand you different trade-offs:
- SSR (Next.js, Remix, Astro, Nuxt): the variation is decided on the server before any HTML reaches the browser. The user never sees the "wrong" version flash, then swap. Best for tests that change above-the-fold content like hero copy, layout, or pricing. See SSR middleware (SDK) for setup.
- Client-side (React without SSR, static sites with the script tag): the script loads, asks for the user's assignments, and hooks like
useVariation()update once ready. Simpler to set up; fine for in-page changes below the fold or for behavioral tests.
Server and client bucketing agree for the same (visitor, flag, iteration) tuple, so you can mix and match without skewing results.
Test mode
Add debug: true to window.badgerlyticsConfigwhile you're wiring things up. Badgerlytics logs every event it sends to the console, including payloads and validation warnings — invaluable for double-checking your funnel and product-view shapes before going live.
window.badgerlyticsConfig = {propertyId: 'your-property-id',debug: true,debugLevel: 'info', // 'error' | 'warn' | 'info' | 'debug'};
Heads up
debug in production. The script is tiny, but console noise on every page view is rough on browser perf and a little embarrassing in DevTools.Useful script functions
Once the script is loaded, window.badgerlytics exposes everything you need. For the full reference (config, automatic events, every method, and payload shapes), see Tracking script API in the sidebar. Quick examples:
// Track when someone views a product (PDP impression)badgerlytics.trackProductView({product_code: 'SKU-123',name: 'Running Shoes',category: 'footwear',price: 9999, // cents});// Track a step in a funnel you've set up in the dashboardbadgerlytics.trackFunnelStep('purchase', 3, 'add_to_cart');// Track a custom event (must be registered in the dashboard first)badgerlytics.trackCustomEvent('custom_newsletter_signup', {source: 'footer',});// Set audience traits for personalization / segmentationbadgerlytics.setTraits({plan: 'pro',signed_in: true,});// Read a feature flag variationconst variation = badgerlytics.getVariation('hero_test'); // 'control' | 'v1' | ...const isOn = badgerlytics.isFlagEnabled('new_pricing_table');
Wait for the tracker
callWhenTrackerReady(fn) helper (from badgerlytics-sdk/react) to queue your call until the tracker is ready. The hooks in our React/Next.js packages do this for you automatically.Tracking script API
Reference for window.badgerlytics and window.badgerlyticsConfig. Each event and method below includes an example and a field table (purpose, type, required, and SaaS notes where relevant).
Note
Configuration
badgerlyticsConfig
Set on window before the script loads. Without propertyId the script exits and does not track.
window.badgerlyticsConfig = {propertyId: 'your-property-id',traits: { plan: 'pro', signed_in: true },debug: false,debugLevel: 'info',disconnected: false,onLoad(tracker) {console.log(tracker.getVariation('hero_test'));},};
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| propertyId | Public property hashid from the app Install snippet. | string | Yes | — |
| traits | Initial audience traits merged into _bai_traits on load. | object | No | — |
| debug | Log initialization and queued events to the console. | boolean | No | — |
| debugLevel | Minimum log level when debug is true. | error | warn | info | debug | verbose | No | — |
| disconnected | Build events but do not send (testing or consent). | boolean | No | — |
| onLoad | Callback after property config finishes loading. | (tracker) => void | No | Runs even if tracking blocked |
On every analytics event
All automatic and manual analytics events include these blocks in addition to any event-specific fields below.
Event envelope
Identifiers and timestamps attached to every ingested event.
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| property_id | Public property hashid the event belongs to. | string | Auto | — |
| visitor_id | Anonymous visitor id from the _bai_visitor cookie. | string | Auto | — |
| session_id | Current session id from the _bai_session cookie. | string | Auto | — |
| session_start_time | ISO timestamp when this session began. | string | Auto | — |
| unique_event_id | Unique id for deduplication on this event. | string | Auto | — |
| event_type | Event name sent to ingestion (e.g. page_view, conversion). | string | Auto | — |
| event_time | ISO timestamp when the event was recorded. | string | Auto | — |
| event_name | Internal routing label; always analytics for script events. | string | Auto | Value: analytics |
Shared context
Device, traffic, and experiment context from getCommonEventData().
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| country_code | Country derived from browser language, when available. | string | null | Auto | — |
| referrer_source | Referring site hostname, or direct when none. | string | Auto | — |
| device_type | Device class for segmentation and reports. | mobile | tablet | desktop | Auto | — |
| browser_name | Detected browser family. | chrome | safari | firefox | edge | opera | ie | other | Auto | — |
| os_name | Detected operating system. | windows | macos | ios | android | linux | other | Auto | — |
| utm_data | UTM parameters from the landing URL. | object | omitted | No | Keys: source, medium, campaign, content, term |
| active_flags | Flag assignments active when the event fired. | { [flag]: { v, i } } | Auto | v = variation, i = iteration |
| organization_id | Public organization hashid from the property embed. | string | null | Auto | — |
Automatic events
You do not call these — the script emits them when tracking is allowed.
session_start (automatic)
Emitted when a new 30-minute session starts. Includes the event envelope and shared context on every analytics event. The ingest response for this event includes a geoLocation object (IP-derived) that the script stores in localStorage for getLocation().
// Automatic — no call required// event_type: "session_start"// Ingest response (when session_start is accepted):// { ok: true, geoLocation: { country: "US", city: "Austin", ... } }
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| is_new_visitor | Whether this is the visitor’s first-ever session. | boolean | Auto | — |
page_view (automatic)
Emitted on each full page load or SPA route change (after the document load event on first paint).
// Automatic — no call required// event_type: "page_view"
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| page_data.path | Pathname of the page viewed. | string | Auto | — |
| session_pageview_number | 1-based pageview index within this session. | integer | Auto | — |
| page_data.page_load_time | Load duration in milliseconds when measurable. | integer | No | Nav Timing or SPA paint |
page_exit (automatic)
Emitted when the user hides the tab or leaves the page. Scroll metrics are collected during the pageview, not as separate events.
// Automatic on tab hide / unload// event_type: "page_exit"
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| page_data.path | Pathname of the page being exited. | string | Auto | — |
| time_on_page | Milliseconds spent on this page. | integer | Auto | — |
| session_duration | Milliseconds since session_start_time. | integer | Auto | — |
| session_pageview_count | Total pageviews in this session so far. | integer | Auto | — |
| max_scroll_depth | Maximum scroll depth reached on this page (0–100). | integer | Auto | — |
| time_to_first_scroll_ms | Ms from page entry to first scroll, if the user scrolled. | integer | null | No | — |
non_bounce_session (automatic)
Emitted on the second pageview in a session for engagement and bounce reporting. No fields beyond the event envelope and shared context.
// Automatic on 2nd pageview in session// event_type: "non_bounce_session"
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| (event-specific) | None — only envelope and shared context. | — | — | — |
experiment_evaluation (automatic)
One impression per (flag, iteration, variation) tuple when the visitor is enrolled or first acknowledged client-side.
// Automatic after flag assignment// test_data: { flag_key, variation_key, iteration, visitor_id, reason }
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| test_data.flag_key | Feature flag key. | string | Auto | — |
| test_data.variation_key | Assigned variation (e.g. control, v1). | string | Auto | — |
| test_data.iteration | Test iteration number. | integer | Auto | — |
| test_data.visitor_id | Visitor id used for bucketing. | string | Auto | — |
| test_data.reason | Why this evaluation was recorded. | bucketed | sticky | forced | iteration_change | variation_retired | Auto | — |
baitestforce (query parameter)
Force flag variations in the browser for local QA (not an API call).
// https://yoursite.com/?baitestforce=hero_test:v1,pricing_test:control
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| baitestforce | Comma-separated flag:variation pairs. | string | No | URL query param |
Commerce & funnels
trackConversion(conversionData)
Record a completed purchase or signup. Invalid payloads throw before send. Monetary values are integers in cents.
badgerlytics.trackConversion({order_id: 'ord_8f2a',order_total: 9950,total_tax: 800,total_discount: 500,total_shipping: 0,currency: 'USD',customer_id: 'cus_abc',subscription_id: 'sub_xyz',products: [{product_code: 'plan_pro',name: 'Pro plan',price: 9950,quantity: 1,billing_interval: 'month',interval_count: 1,}],});
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| order_id | Your order or transaction id. | string | Yes | — |
| order_total | Order total in smallest currency unit. | integer | Yes | Cents; ≥ 0 |
| total_tax | Tax amount in cents. | integer | No | Default 0 |
| total_discount | Discount amount in cents. | integer | No | Default 0 |
| total_shipping | Shipping amount in cents. | integer | No | Default 0 |
| currency | ISO 4217 currency code. | string | No | Default USD |
| customer_id | Stable customer id for retention and churn. | string | No | SaaS |
| subscription_id | Links purchase to subscription lifecycle webhooks. | string | No | SaaS |
| discounts | Applied discounts on the order. | array | No | — |
| discounts[].discount_name | Human-readable discount label. | string | Yes | If discounts sent |
| discounts[].discount_amount | Discount value in cents. | integer | Yes | If discounts sent |
| discounts[].code | Promo or coupon code. | string | No | — |
| products | Line items on the order. | array | No | — |
| products[].product_code | Primary product or plan identifier. | string | Yes | When products[] is sent |
| products[].name | Display name shown in reports. | string | Yes | When products[] is sent |
| products[].sku | Secondary line-item id for joins. | string | No | When products[] is sent; Defaults to product_code |
| products[].variant | Variant label (size, tier, etc.). | string | No | When products[] is sent |
| products[].category | Product category for breakdowns. | string | No | When products[] is sent |
| products[].price | Unit price in smallest currency unit. | integer | No | When products[] is sent; Cents; default 0 |
| products[].quantity | Units in the line item. | integer | No | When products[] is sent; Min 1; default 1 |
| products[].billing_interval | Billing cadence for MRR and subscription reports. | one_time | day | week | month | year | No | When products[] is sent; SaaS |
| products[].interval_count | Intervals per billing period (e.g. 3 months). | integer | No | When products[] is sent; SaaS |
| page_data.path | Path where conversion fired. | string | Auto | — |
trackProductView(productData)
Product detail (PDP) impression. Duplicate product_code on the same pageview is ignored.
badgerlytics.trackProductView({product_code: 'SKU-7421',name: 'Trail Runner',category: 'footwear',price: 12999,});
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| product_code | Primary product identifier. | string | Yes | — |
| name | Display name for reports. | string | Yes | — |
| sku | Secondary id for view-to-purchase joins. | string | No | Defaults to product_code |
| variant | Variant label when applicable. | string | No | — |
| category | Product category. | string | No | — |
| price | Listed price in cents. | integer | No | Default 0 |
| page_data.path | Path where the product was viewed. | string | Auto | — |
trackCartUpdate(cartData)
Snapshot of cart or plan-selection intent after any change. Powers abandoned-cart reporting when no conversion follows within the window.
badgerlytics.trackCartUpdate({subtotal: 25998,currency: 'USD',cart_id: 'cart_42',customer_id: 'cus_abc',products: [{product_code: 'plan_pro',name: 'Pro plan',price: 25998,quantity: 1,billing_interval: 'month',}],});
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| subtotal | Current cart total in cents. | integer | Yes | ≥ 0 |
| currency | ISO 4217 currency code. | string | No | Default USD |
| cart_id | Your cart correlator. | string | No | — |
| customer_id | Stable customer id when known. | string | No | SaaS |
| subscription_id | Subscription correlator when upgrading. | string | No | SaaS |
| products | Line items currently in the cart. | array | Yes | Min 1 item |
| products[].product_code | Primary product or plan identifier. | string | Yes | Per line item |
| products[].name | Display name shown in reports. | string | Yes | Per line item |
| products[].sku | Secondary line-item id for joins. | string | No | Defaults to product_code |
| products[].variant | Variant label (size, tier, etc.). | string | No | Per line item |
| products[].category | Product category for breakdowns. | string | No | Per line item |
| products[].price | Unit price in smallest currency unit. | integer | No | Cents; default 0 |
| products[].quantity | Units in the line item. | integer | No | Min 1; default 1 |
| products[].billing_interval | Billing cadence for MRR and subscription reports. | one_time | day | week | month | year | No | SaaS |
| products[].interval_count | Intervals per billing period (e.g. 3 months). | integer | No | SaaS |
| page_data.path | Path where the cart was updated. | string | Auto | — |
trackFunnelStep(funnelName, stepNumber, stepName)
Record progress through a named funnel configured in the dashboard.
badgerlytics.trackFunnelStep('purchase', 3, 'add_to_cart');
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| funnelName | Funnel name matching dashboard setup. | string | Yes | Method argument |
| stepNumber | Numeric step index (1-based). | integer | Yes | Method argument |
| stepName | Human-readable step label. | string | Yes | Method argument |
| funnel_data.funnel_name | Echo of funnelName on the event. | string | Auto | — |
| funnel_data.step_number | Echo of stepNumber on the event. | integer | Auto | — |
| funnel_data.step_name | Echo of stepName on the event. | string | Auto | — |
| funnel_data.furthest_step | Highest step number reached this session. | integer | Auto | — |
| funnel_data.is_step_advance | True when this step is new progress. | boolean | Auto | — |
trackCustomEvent(eventToken, metadata?)
Fire a dashboard-registered custom event. Unregistered tokens are dropped.
badgerlytics.trackCustomEvent('custom_newsletter_signup', {source: 'footer',});
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| eventToken | Token from Custom events in the dashboard. | string | Yes | Method argument; custom_ prefix |
| metadata | Optional key/value payload. | object | No | Method argument |
| event_name | Same value as eventToken on the ingested event. | string | Auto | — |
| event_metadata | Metadata object attached to the event. | object | No | — |
Feature flags & traits
getVariation(flagKey)
Return the assigned variation key for a flag.
const hero = badgerlytics.getVariation('hero_test');
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| flagKey | Feature flag key from the dashboard. | string | Yes | — |
Returns: string | null
getActiveFlags(flagKey?)
Return all flag assignments, or one variation when flagKey is passed.
const all = badgerlytics.getActiveFlags();const hero = badgerlytics.getActiveFlags('hero_test');
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| flagKey | Optional single flag to look up. | string | No | — |
Returns: object | string | null
getFlagIteration(flagKey)
Return the current test iteration number for a flag.
const iter = badgerlytics.getFlagIteration('hero_test');
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| flagKey | Feature flag key. | string | Yes | — |
Returns: number | null
isFlagEnabled(flagKey)
Convenience check: true for v1/on, false for control/off/v0/v2 or no assignment.
if (badgerlytics.isFlagEnabled('new_pricing_table')) { ... }
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| flagKey | Feature flag key. | string | Yes | — |
Returns: boolean
onFlagsReady(callback)
Run callback when flag initialization completes (immediately if already ready).
badgerlytics.onFlagsReady(() => {const v = badgerlytics.getVariation('hero_test');});
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| callback | Function invoked when assignments are available. | () => void | Yes | — |
Returns: void
refreshFlags()
Re-run segment and bucketing after traits change. Preserves sticky assignments.
badgerlytics.setTraits({ plan: 'enterprise' });// refreshFlags() is called automatically by setTraits
No parameters.
Returns: Promise<void>
getAllFlags()
Raw experiment config from the property embed (organizationId + experiments).
const cfg = badgerlytics.getAllFlags();
No parameters.
Returns: { organizationId, experiments } | null
setTraits(partial)
Merge custom audience traits into the cookie and re-evaluate segment rules. Pass null to clear on logout.
badgerlytics.setTraits({ plan: 'pro', signed_in: true });badgerlytics.setTraits(null); // logout
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| partial | Traits to merge, or null to clear all custom traits. | object | null | Yes | — |
Returns: void
getTraits()
Read-only snapshot of custom traits plus built-ins derived at call time.
const traits = badgerlytics.getTraits();// { visitor_type, device_type, page_path, utm_source?, plan?, ... }
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| visitor_type | new or returning based on visitor cookie. | string | Auto | Built-in |
| device_type | Current device class. | string | Auto | Built-in |
| page_path | Current pathname. | string | Auto | Built-in |
| utm_source | UTM source when present on URL. | string | No | Built-in |
| utm_medium | UTM medium when present. | string | No | Built-in |
| utm_campaign | UTM campaign when present. | string | No | Built-in |
| (custom keys) | Keys you set via setTraits or config.traits. | string | number | boolean | No | — |
Returns: object
Visitor location
Location is derived from the visitor's IP at ingest time. It is returned on the session_start ingest response only, then cached for the session. Call getLocation() after onSessionStartReady — it returns null if you read it before that response arrives.
getLocation()
Read IP-derived location for the current session. Returns null until the session_start ingest response arrives. Stored in localStorage (_bai_user_location) and expires with the session (30 minutes from session start). Only set on new sessions — a resumed session within the window reuses the stored value.
badgerlytics.onSessionStartReady(() => {const loc = badgerlytics.getLocation();if (!loc) return;console.log(loc.city, loc.regionCode);});
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| country | Two-letter country code (e.g. US). | string | null | Auto | — |
| city | City name when known. | string | null | Auto | — |
| continent | Continent code (e.g. NA). | string | null | Auto | — |
| region | First-level region name (e.g. Texas). | string | null | Auto | — |
| regionCode | ISO 3166-2 region code (e.g. TX). | string | null | Auto | — |
| timezone | IANA timezone (e.g. America/Chicago). | string | null | Auto | — |
| longitude | Approximate longitude from the visitor IP. | string | null | Auto | — |
| latitude | Approximate latitude from the visitor IP. | string | null | Auto | — |
| postalCode | Postal or ZIP code when known. | string | null | Auto | — |
| metroCode | Nielsen DMA (Designated Market Area) code for US TV markets. | string | null | Auto | DMADMA code listings |
Returns: object | null
onSessionStartReady(callback)
Run callback when the session_start ingest response has returned and geoLocation is stored for this session. Fires immediately if location is already available (e.g. resumed session within 30 minutes).
badgerlytics.onSessionStartReady(() => {const loc = badgerlytics.getLocation();if (loc?.metroCode === '635') {// Austin DMA}});
| Field | Purpose | Type | Required | Notes |
|---|---|---|---|---|
| callback | Function invoked when getLocation() will return data. | () => void | Yes | — |
Returns: void
Session control
disconnect()
Stop sending events and clear the outbound queue. Cookies unchanged.
badgerlytics.disconnect();
No parameters.
Returns: void
connect()
Resume sending after disconnect() or when loaded with disconnected: true.
badgerlytics.connect();
No parameters.
Returns: void
SSR middleware (SDK)
Overview
The badgerlytics-sdk middleware packages assign A/B test and feature-flag variations on the server before HTML is sent, so visitors never see a flash of the wrong variant. Install the package for your framework, add middleware once, then read assignments in pages, loaders, or server components.
npm install badgerlytics-sdk
Import paths and peer dependencies:
badgerlytics-sdk/nextjs— requiresnext>= 13.4badgerlytics-sdk/remix— React Router 7+ or Remix 2+badgerlytics-sdk/nuxt— Nuxt 3+ (includesh3)badgerlytics-sdk/astro— Astro 4+
Middleware shares one assignment engine across frameworks. You still need the browser tracking script for analytics events — middleware only handles assignment cookies and SSR reads:
<!-- Still required for page views, commerce events, and client-side flags --><script>window.badgerlyticsConfig = { propertyId: 'your-property-id' };</script><script src="https://cdn.badgerlytics.com/scripts/badgerlytics.min.js" async></script>
Client-only apps
badgerlytics-sdk/react (useVariation, useSetTraits) instead of middleware. See Script Installation → React.Next.js
Add root middleware.js, then read assignments from req (Pages Router) or headers() (App Router).
// middleware.js (project root)import { createBadgerlyticsMiddleware } from 'badgerlytics-sdk/nextjs';export const middleware = createBadgerlyticsMiddleware({propertyId: 'your-property-id',});export const config = {matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],};
Read assignments — Pages Router:
import {getVariation,getAssignments,getVisitorId,getTraits,isFlagEnabled,} from 'badgerlytics-sdk/nextjs';export async function getServerSideProps({ req }) {return {props: {hero: getVariation(req, 'hero_test', 'control'),allFlags: getAssignments(req),visitorId: getVisitorId(req),traits: getTraits(req),pricingOn: isFlagEnabled(req, 'new_pricing_table'),},};}
Read assignments — App Router:
import { headers } from 'next/headers';import {getVariation,getAssignments,getTraits,isFlagEnabled,} from 'badgerlytics-sdk/nextjs';export default async function Page() {const h = await headers();const hero = getVariation(h, 'hero_test', 'control');const traits = getTraits(h);const pricingOn = isFlagEnabled(h, 'new_pricing_table');return hero === 'v1' ? <HeroB traits={traits} /> : <HeroA />;}
Set audience traits — Route Handlers and legacy pages/api:
import { setTraitsOnResponse } from 'badgerlytics-sdk/nextjs';// App Router Route Handlerexport async function POST(request) {const user = await authenticate(request);const response = Response.json({ ok: true });setTraitsOnResponse(response, { signed_in: true, plan: user.plan });return response;}// Legacy pages/api — Node res is supportedexport default function handler(req, res) {setTraitsOnResponse(res, { signed_in: true });res.status(200).json({ ok: true });}
Manual assignment (without global middleware):
import { runBadgerlyticsAssignment } from 'badgerlytics-sdk/nextjs';export async function GET(request) {const result = await runBadgerlyticsAssignment(request, {propertyId: 'your-property-id',});if (!result.ok) return Response.json({ skipped: result.reason });const response = Response.json({ assignments: result.assignments });for (const cookie of result.setCookieHeaders) {response.headers.append('Set-Cookie', cookie);}return response;}
Next.js only: writeAssignmentCookies(NextResponse, opts) — used by the built-in middleware to set visitor + flags via NextResponse.cookies.
Remix / React Router
Register middleware in app/middleware.ts. Loaders and actions receive a request with forwarded x-bai-* headers — pass that to getVariation(request, …).
// app/middleware.tsimport { createBadgerlyticsMiddleware } from 'badgerlytics-sdk/remix';export const middleware = [createBadgerlyticsMiddleware({ propertyId: 'your-property-id' }),];
Read assignments in a loader:
import {getVariation,getAssignments,getVisitorId,getTraits,isFlagEnabled,isVariation,} from 'badgerlytics-sdk/remix';export async function loader({ request }) {return {hero: getVariation(request, 'hero_test', 'control'),allFlags: getAssignments(request),visitorId: getVisitorId(request),traits: getTraits(request),isHeroV1: isVariation(request, 'hero_test', 'v1'),pricingOn: isFlagEnabled(request, 'new_pricing_table'),};}
Set audience traits in an action:
import { setTraitsOnResponse } from 'badgerlytics-sdk/remix';import { redirect } from 'react-router';export async function action() {const user = await login();const response = redirect('/app');setTraitsOnResponse(response, { signed_in: true, plan: user.plan });return response;}
Manual assignment:
import {runBadgerlyticsAssignment,writeAssignmentCookiesOnResponse,} from 'badgerlytics-sdk/remix';export async function loader({ request }) {const result = await runBadgerlyticsAssignment(request, {propertyId: 'your-property-id',});if (!result.ok) return { skipped: result.reason };const headers = new Headers(result.forwardHeaders);// Merge forwardHeaders onto a sub-request, or return assignments in loader datareturn { assignments: result.assignments };}
Also available: writeAssignmentCookiesOnResponse(response, opts) to append visitor + flags Set-Cookie headers on a Web Response.
Nuxt
Add server/middleware/badgerlytics.ts. Server routes read from event.context.badgerlytics via the *FromEvent helpers — not getVariation(event).
// server/middleware/badgerlytics.tsimport { createBadgerlyticsMiddleware } from 'badgerlytics-sdk/nuxt';export default createBadgerlyticsMiddleware({propertyId: 'your-property-id',});
Read assignments:
import {getVariationFromEvent,getAssignmentsFromEvent,getVisitorIdFromEvent,getTraitsFromEvent,isFlagEnabledFromEvent,} from 'badgerlytics-sdk/nuxt';export default defineEventHandler((event) => {return {hero: getVariationFromEvent(event, 'hero_test', 'control'),allFlags: getAssignmentsFromEvent(event),visitorId: getVisitorIdFromEvent(event),traits: getTraitsFromEvent(event),pricingOn: isFlagEnabledFromEvent(event, 'new_pricing_table'),};});
Set audience traits — use setTraitsOnEvent (h3), not setTraitsOnResponse:
import { setTraitsOnEvent } from 'badgerlytics-sdk/nuxt';export default defineEventHandler(async (event) => {const user = await login(event);setTraitsOnEvent(event, { signed_in: true, plan: user.plan });return { ok: true };});
Manual assignment:
import { runBadgerlyticsAssignmentForEvent } from 'badgerlytics-sdk/nuxt';export default defineEventHandler(async (event) => {const result = await runBadgerlyticsAssignmentForEvent(event, {propertyId: 'your-property-id',});if (!result.ok) return { skipped: result.reason };return { assignments: result.assignments };});
Nuxt-specific exports:
getVariationFromEvent,getAssignmentsFromEvent,getVisitorIdFromEvent,getTraitsFromEvent,isFlagEnabledFromEventsetTraitsOnEvent(event, traits)runBadgerlyticsAssignmentForEvent(event, options)
Astro
Wrap createBadgerlyticsMiddleware in defineMiddleware. Read assignments from Astro.locals — Astro does not replace the incoming request, so header-based getVariation will not work in .astro frontmatter.
// src/middleware.jsimport { defineMiddleware } from 'astro:middleware';import { createBadgerlyticsMiddleware } from 'badgerlytics-sdk/astro';export const onRequest = defineMiddleware(createBadgerlyticsMiddleware({ propertyId: 'your-property-id' }));
Read assignments in a page:
---import {getVariationFromLocals,getAssignmentsFromLocals,getVisitorIdFromLocals,getTraitsFromLocals,isFlagEnabledFromLocals,} from 'badgerlytics-sdk/astro';const hero = getVariationFromLocals(Astro.locals, 'hero_test', 'control');const traits = getTraitsFromLocals(Astro.locals);const pricingOn = isFlagEnabledFromLocals(Astro.locals, 'new_pricing_table');---{hero === 'v1' ? <HeroB traits={traits} /> : <HeroA />}
Set audience traits in an API route:
import { setTraitsOnResponse } from 'badgerlytics-sdk/astro';export const POST = async ({ request }) => {const user = await authenticate(request);const response = new Response(JSON.stringify({ ok: true }), {headers: { 'Content-Type': 'application/json' },});setTraitsOnResponse(response, { signed_in: true, plan: user.plan });return response;};
Manual assignment:
import { runBadgerlyticsAssignment } from 'badgerlytics-sdk/astro';export const GET = async ({ request }) => {const result = await runBadgerlyticsAssignment(request, {propertyId: 'your-property-id',});if (!result.ok) return new Response(JSON.stringify({ skipped: result.reason }));const response = new Response(JSON.stringify({ assignments: result.assignments }));for (const cookie of result.setCookieHeaders) {response.headers.append('Set-Cookie', cookie);}return response;};
Astro-specific exports (all use Astro.locals):
getVariationFromLocals,getAssignmentsFromLocals,getVisitorIdFromLocals,getTraitsFromLocals,isFlagEnabledFromLocals
Framework differences
- Where to read flags: Next.js and Remix →
getVariation(request | req | headers()). Nuxt →getVariationFromEvent(event). Astro →getVariationFromLocals(Astro.locals). - Setting traits on login: Next.js, Remix, and Astro API routes →
setTraitsOnResponse. Nuxt →setTraitsOnEvent. - Manual assignment without middleware: Web
Request→runBadgerlyticsAssignment. Nuxt →runBadgerlyticsAssignmentForEvent. - Edge vs Node: Middleware runs wherever your framework deploys it. The property CDN embed must be reachable from that runtime.
- Bucketing parity: Server and browser use the same
(visitorId, flagKey, iteration)seed, so SSR and client assignments stay aligned when cookies are present.
Consent and assignment cookies
Ecommerce Reporting Setup
Ecommerce reports are entirely client-side — fire the four event types below from the right points in your shopping flow and the dashboards light up.
Funnels
A standard purchase funnel works well for most stores:
- 1 — Product listing page (PLP)
- 2 — Product display page (PDP)
- 3 — Add to cart
- 4 — View cart
- 5 — Payment / review
- 6 — Conversion
Set these up once under Funnels and call trackFunnelStep('purchase', n, name) as users reach each step.
Product view
Fire one product view per PDP visit. The event is what powers the "most-viewed products" report and the view-to-purchase conversion rate.
// On every product detail pagewindow.badgerlytics.trackProductView({product_code: 'SKU-7421',name: 'Trail Runner — Size 10',category: 'footwear',price: 12999, // cents});
Cart update
Fire after any change to the cart. The payload should always reflect the full cart, not just the delta — we use it as a snapshot for the abandoned-cart reports.
// After any cart change: add, remove, qty updatewindow.badgerlytics.trackCartUpdate({subtotal: 25998, // cents — full cart subtotalcurrency: 'USD',cart_id: cartId,products: cart.items.map((item) => ({product_code: item.sku,name: item.name,category: item.category,price: item.priceCents,quantity: item.quantity,})),});
Skip empty carts
Conversion
Fire on the order-confirmation page. Include the full line-item breakdown — we use it for revenue-by-category, revenue-by-SKU, and to attribute the purchase back to the product views that led to it.
// On the order-confirmation pagebadgerlytics.trackConversion({order_id: order.id, // your order idorder_total: order.totalCents,total_tax: order.taxCents, // optionaltotal_discount: order.discountCents, // optionaltotal_shipping: order.shippingCents, // optionalcurrency: 'USD',customer_id: order.customerId, // optional, enables cohort retentiondiscounts: order.discounts?.map((d) => ({code: d.code,discount_name: d.name,discount_amount: d.amountCents,})),products: order.lineItems.map((item) => ({product_code: item.sku,sku: item.sku,name: item.name,category: item.category,price: item.priceCents,quantity: item.quantity,// billing_interval defaults to 'one_time' for ecommerce})),});
Abandoned cart considerations
- A cart streak is "abandoned" when the visitor sends a
cart_updateand noconversionarrives within a locked 7-day window after the last cart update. The report only includes streaks whose window has fully closed (seecomplete_throughin the app). - Conversions that happen later — even after those 7 days — still mark the streak as converted when we see them in your data (we look ahead up to 30 days when classifying streaks). A customer who returns two days later and buys counts as converted, not abandoned.
SaaS Reporting Setup
SaaS reporting joins three streams: in-page events from the tracking script (your marketing funnel), a conversion event at signup or upgrade, and subscription_changeevents from your backend for everything that happens after (cancellations, upgrades, downgrades, reactivations). Here's how to wire each one up.
Define your plans first
plan_code values you use below must match the catalogue, and the SaaS Setup wizard in the app generates this code pre-filled with your actual plans.Funnels
A typical SaaS funnel: landing → pricing → signup → activation → conversion. Set this up under Custom events / Funnel setup → Funnels and fire each step with badgerlytics.trackFunnelStep(name, n, step) as the user reaches it.
Product view
Fire one product view per plan a visitor sees on your pricing page. This powers the "most-viewed plan" numbers and the plan-level conversion rates.
// Fire when a visitor lands on a plan / pricing pagebadgerlytics.trackProductView({product_code: 'pro_monthly', // your plan_codename: 'Pro (Monthly)',category: 'pro', // your tier_labelprice: 15900, // cents});
Cart update
Once the user has selected a specific plan and started checking out, send a cart update so we can attribute their conversion (or abandonment) back to the plan they chose.
// Fire after the visitor picks a plan (e.g. on the checkout / review step)badgerlytics.trackCartUpdate({subtotal: 15900,currency: 'USD',cart_id: 'session-abc123',products: [{product_code: 'pro_monthly',name: 'Pro (Monthly)',category: 'pro',price: 15900,quantity: 1,},],});
Conversion
Fire a conversion event when a subscription actually starts. For SaaS, a conversion can mean any of these — pick the definition that matches your business:
- Trial start: credit card collected; use
order_total: 0and the plan's eventual MRR price on the product line. - Paid start: first paid invoice. Fire conversion with the actual amount charged.
- Both: some teams fire one conversion at trial start and another at trial → paid. Use distinct
order_idvalues to keep them separate.
The recurring billing_interval + interval_count on the product line are what mark this conversion as new MRR (rather than a one-time purchase).
// Fire from your checkout success handler when a subscription starts.// Including billing_interval + interval_count is what marks this conversion// as recurring (new MRR) instead of a one-time purchase.badgerlytics.trackConversion({order_id: 'order_' + Date.now(),order_total: 15900, // centscustomer_id: '<your_user_id>',subscription_id: '<your_subscription_id>',products: [{product_code: 'pro_monthly',name: 'Pro (Monthly)',price: 15900,quantity: 1,billing_interval: 'month',interval_count: 1,category: 'pro',},],});
Lifecycle events webhook
Conversion fires once, at signup. Everything after that — cancellations, upgrades, downgrades, renewals, trial conversions — happens in your billing system. Forward each state change to us as a subscription_changeevent from your server, and we'll keep MRR, churn, expansion, contraction, and cancellation-reason reports accurate.
Endpoint and auth
Endpoint: POST https://events.badgerlytics.com/ingestion-api/events
Auth: subscription_change is a server-only event type. It must be sent with an Authorization: Bearer <secret_api_key> header tied to this property's organization. Generate secret keys under Organization settings → API keys. Browser ingestion does not work for this event type — that's intentional, so customers can't mint MRR by calling fetch from devtools.
Change types: cancel, upgrade, downgrade, renewal, and trial_converted. Include a positive or negative mrr_delta_cents matching the direction of the change ( 0 for plain renewals).
Quick curl smoke test:
curl -X POST https://events.badgerlytics.com/ingestion-api/events \-H "Content-Type: application/json" \-H "Authorization: Bearer <your_secret_api_key>" \-d '{"property_id": "<your_property_id>","event_type": "subscription_change","unique_event_id": "<uuid-v4>","session_id": "<server-side-session-uuid>","visitor_id": "<server-side-visitor-uuid>","event_time": "<ISO-8601-now>","subscription_data": {"subscription_id": "<your_subscription_id>","customer_id": "<your_user_id>","change_type": "cancel","mrr_delta_cents": -15900,"previous_plan_code": "pro_monthly","currency": "USD","cancel_reason": "too_expensive","effective_at": "<ISO-8601-effective-date>"}}'
Or skip writing the adapter yourself and drop in a Stripe webhook forwarder — the app's SaaS Setup wizard generates this exact snippet pre-configured for your property:
// Cloudflare Worker / Vercel edge function.// Receives Stripe customer.subscription.* webhooks and forwards them as// Badgerlytics subscription_change events. Set BADGERLYTICS_SECRET_KEY// and PROPERTY_ID as environment variables.export default {async fetch(req, env) {const event = await req.json();const ev = event.data?.object || {};let change_type;let mrr_delta_cents;if (event.type === 'customer.subscription.deleted') {change_type = 'cancel';mrr_delta_cents = -(ev.items?.data?.[0]?.price?.unit_amount || 0);} else if (event.type === 'customer.subscription.updated') {const prev = event.data.previous_attributes?.items?.data?.[0]?.price?.unit_amount;const next = ev.items?.data?.[0]?.price?.unit_amount || 0;if (prev && next && next !== prev) {change_type = next > prev ? 'upgrade' : 'downgrade';mrr_delta_cents = next - prev;} else {change_type = 'renewal';mrr_delta_cents = 0;}} else if (event.type === 'customer.subscription.created') {change_type = 'trial_converted';mrr_delta_cents = ev.items?.data?.[0]?.price?.unit_amount || 0;} else {return new Response('ignored', { status: 204 });}const body = {property_id: env.PROPERTY_ID,event_type: 'subscription_change',unique_event_id: crypto.randomUUID(),session_id: 'srv_' + crypto.randomUUID(),visitor_id: 'srv_' + (ev.customer || 'unknown'),event_time: new Date().toISOString(),customer_id: String(ev.customer || ''),subscription_data: {subscription_id: String(ev.id || ''),customer_id: String(ev.customer || ''),change_type,mrr_delta_cents,previous_plan_code:event.data.previous_attributes?.items?.data?.[0]?.price?.lookup_key || '',new_plan_code: ev.items?.data?.[0]?.price?.lookup_key || '',currency: (ev.currency || 'usd').toUpperCase(),cancel_reason: ev.cancellation_details?.reason || '',},};await fetch('https://events.badgerlytics.com/ingestion-api/events', {method: 'POST',headers: {'Content-Type': 'application/json',Authorization: 'Bearer ' + env.BADGERLYTICS_SECRET_KEY,},body: JSON.stringify(body),});return new Response('ok', { status: 200 });},};
import crypto from 'node:crypto';async function reportSubscriptionChange({propertyId,secretKey,subscriptionId,customerId,changeType, // 'cancel' | 'upgrade' | 'downgrade' | 'renewal' | 'trial_converted'mrrDeltaCents,previousPlanCode = '',newPlanCode = '',cancelReason = '',}) {const body = {property_id: propertyId,event_type: 'subscription_change',unique_event_id: crypto.randomUUID(),session_id: 'srv_' + crypto.randomUUID(),visitor_id: 'srv_' + customerId,event_time: new Date().toISOString(),customer_id: customerId,subscription_data: {subscription_id: subscriptionId,customer_id: customerId,change_type: changeType,mrr_delta_cents: mrrDeltaCents,previous_plan_code: previousPlanCode,new_plan_code: newPlanCode,currency: 'USD',cancel_reason: cancelReason,effective_at: new Date().toISOString(),},};const res = await fetch('https://events.badgerlytics.com/ingestion-api/events', {method: 'POST',headers: {'Content-Type': 'application/json',Authorization: 'Bearer ' + secretKey,},body: JSON.stringify(body),});if (!res.ok) throw new Error('Badgerlytics ingest failed: ' + res.status);}
<?phpfunction reportSubscriptionChange(array $args): void {$body = ['property_id' => $args['property_id'],'event_type' => 'subscription_change','unique_event_id' => bin2hex(random_bytes(16)),'session_id' => 'srv_' . bin2hex(random_bytes(16)),'visitor_id' => 'srv_' . $args['customer_id'],'event_time' => gmdate('c'),'customer_id' => $args['customer_id'],'subscription_data' => ['subscription_id' => $args['subscription_id'],'customer_id' => $args['customer_id'],'change_type' => $args['change_type'], // cancel | upgrade | downgrade | renewal | trial_converted'mrr_delta_cents' => $args['mrr_delta_cents'],'previous_plan_code' => $args['previous_plan_code'] ?? '','new_plan_code' => $args['new_plan_code'] ?? '','currency' => 'USD','cancel_reason' => $args['cancel_reason'] ?? '','effective_at' => gmdate('c'),],];$ch = curl_init('https://events.badgerlytics.com/ingestion-api/events');curl_setopt_array($ch, [CURLOPT_POST => true,CURLOPT_RETURNTRANSFER => true,CURLOPT_HTTPHEADER => ['Content-Type: application/json','Authorization: Bearer ' . $args['secret_key'],],CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_SLASHES),]);curl_exec($ch);curl_close($ch);}
package badgerlyticsimport ("bytes""encoding/json""net/http""time""github.com/google/uuid")type SubscriptionChange struct {PropertyID stringSecretKey stringSubscriptionID stringCustomerID stringChangeType string // cancel | upgrade | downgrade | renewal | trial_convertedMRRDeltaCents intPreviousPlanCode stringNewPlanCode stringCancelReason string}func ReportSubscriptionChange(s SubscriptionChange) error {now := time.Now().UTC().Format(time.RFC3339)body := map[string]any{"property_id": s.PropertyID,"event_type": "subscription_change","unique_event_id": uuid.NewString(),"session_id": "srv_" + uuid.NewString(),"visitor_id": "srv_" + s.CustomerID,"event_time": now,"customer_id": s.CustomerID,"subscription_data": map[string]any{"subscription_id": s.SubscriptionID,"customer_id": s.CustomerID,"change_type": s.ChangeType,"mrr_delta_cents": s.MRRDeltaCents,"previous_plan_code": s.PreviousPlanCode,"new_plan_code": s.NewPlanCode,"currency": "USD","cancel_reason": s.CancelReason,"effective_at": now,},}b, _ := json.Marshal(body)req, _ := http.NewRequest("POST","https://events.badgerlytics.com/ingestion-api/events",bytes.NewReader(b),)req.Header.Set("Content-Type", "application/json")req.Header.Set("Authorization", "Bearer "+s.SecretKey)resp, err := http.DefaultClient.Do(req)if err != nil {return err}defer resp.Body.Close()return nil}
import jsonimport uuidfrom datetime import datetime, timezonefrom urllib.error import HTTPErrorfrom urllib.request import Request, urlopenINGEST_URL = "https://events.badgerlytics.com/ingestion-api/events"def report_subscription_change(*,property_id: str,secret_key: str,subscription_id: str,customer_id: str,change_type: str, # cancel | upgrade | downgrade | renewal | trial_convertedmrr_delta_cents: int,previous_plan_code: str = "",new_plan_code: str = "",cancel_reason: str = "",) -> None:now = datetime.now(timezone.utc).isoformat()body = {"property_id": property_id,"event_type": "subscription_change","unique_event_id": str(uuid.uuid4()),"session_id": "srv_" + str(uuid.uuid4()),"visitor_id": "srv_" + customer_id,"event_time": now,"customer_id": customer_id,"subscription_data": {"subscription_id": subscription_id,"customer_id": customer_id,"change_type": change_type,"mrr_delta_cents": mrr_delta_cents,"previous_plan_code": previous_plan_code,"new_plan_code": new_plan_code,"currency": "USD","cancel_reason": cancel_reason,"effective_at": now,},}req = Request(INGEST_URL,data=json.dumps(body).encode(),headers={"Content-Type": "application/json","Authorization": "Bearer " + secret_key,},method="POST",)try:with urlopen(req) as res:if res.status >= 400:raise RuntimeError("Badgerlytics ingest failed: " + str(res.status))except HTTPError as e:raise RuntimeError("Badgerlytics ingest failed: " + str(e.code)) from e
Idempotency
subscription_id and fire a fresh unique_event_idper call. If your billing provider retries on transient errors, that's fine — duplicates with the same unique_event_id are dropped on our side, so retries are safe.Common Considerations
Bot handling
By default we try to keep crawlers and headless automation out of your analytics. Checks run in the browser before the tracking script sends any events:
- User-agent patterns — known bots, crawlers, link preview fetchers, and common automation clients (for example Googlebot, Lighthouse,
curl). - Lightweight browser signals — for example
navigator.webdriver, headless-Chrome tells, empty plugin lists, and missing language preferences. We do not classify bots from scroll depth, clicks, time on page, or bounce-style engagement.
When a visit matches, the script does not initialize tracking, so those sessions never produce browser analytics events. Server-side flag assignment can use the same user-agent list (see skipBots in the SDK). We do not maintain IP-reputation blocklists for bot filtering.
Events you send through our server APIs (webhooks, subscription changes, and similar) are not run through these browser checks.
Traffic filters
Traffic filters let you exclude specific visitors from analytics for a property — for example your office network, a staging hostname, or your own IP while you click around the site. They are a denylist: anything that matches a rule is dropped before it counts toward reports. This is separate from your property's allowed domain, which controls which sites are allowed to send data at all.
Configure them in the app under Settings → Traffic filters. Pick a property, turn filtering on, add one or more rules, and save. Saved rules are published to that property's CDN config within seconds so the tracking script and our edge see the same rules on the next page load.
Not the same as your property domain
Your property domain (e.g. www.example.com) is an allowlist: tracking only works on that site. Traffic filters are extra exclusions on top — useful for "I'm on the right site but I still don't want my visits counted."
Rule types
- IP address — block a single IPv4 or IPv6 address. Use the address your browser actually sends to our edge (see below); many home networks today use IPv6.
- IP range (CIDR) — block a subnet, e.g. office Wi‑Fi (
203.0.113.0/24) or an IPv6 prefix. - Page hostname — block visitors browsing on a specific host, such as
staging.example.comorlocalhost:3000. - Referrer hostname — block when traffic arrived from a particular referring site (less common; referrers can be stripped by browser privacy settings).
For hostname rules, enter the host only — not a full URL. Use staging.example.com or localhost:3000, not https://staging.example.com. If you paste a full URL, we trim it to the host when you save. Do not use our events API hostname (e.g. events.badgerlytics.com); that is only the delivery address for beacons, not your shop's hostname.
The settings page shows a blocked ingest attempts counter for blocks recorded at the edge (after you save filters). Browser-only blocks are not included in that number. Filtering must be enabled and you need at least one valid rule; an empty rule list does not block anything.
Event ingestion delay
Badgerlytics does not offer real-time analytics. Events are durable and processed in order — they may show up later, but they don't go missing. Under heavy load or during traffic spikes the queue can grow, which adds to the delay.
- Most reports refresh on a rolling schedule. Same-day data continues to fill in as events are processed.
- The website performance report updates at most hourly — that is the freshest cadence in the product today.
- AI insights run on a daily cadence and use the previous day's finalized data.
Maintenance / downtime
We continue to ingest events even when the app dashboard is down. Routine maintenance (deployments, schema changes) only takes the UI offline; the events worker keeps accepting payloads and queues them for processing. When the app comes back up, your reports backfill from the queue. You don't need to do anything.
Property data / CDN caching
Each property publishes a small JSON config (active feature flags, audience definitions, plan catalogue for SaaS, etc.) to a CDN that our tracking script reads on every page. We make a real effort to send Cache-Control: no-store on this object and keep it fresh, but please:
- Exclude the property CDN from your own caching layer. If you proxy our script or its config through Cloudflare, CloudFront, or Fastly, set a bypass rule for
cdn.badgerlytics.comandapp.badgerlytics.com. - Updates to feature flags, audiences, custom events, traffic filters, and SaaS plans propagate to the CDN within seconds of saving in the dashboard. Changes are essentially immediate to your visitors.
Organizations
Setup and purpose
An organization is the top-level container for your team and billing. When you sign up you create (or get invited to) exactly one organization. Inside that organization you create one or more properties, which are the websites or apps you actually track.
The split lets you:
- Bill for multiple sites under one plan.
- Share a team across several properties.
- Keep ownership of audiences, custom events, and feature flag definitions inside the property so they don't leak between sites.
Users and roles
Each user in your organization has one of three roles:
- Owner: can manage billing, invite users, and create or delete properties. There's always at least one. Owners can't be removed from the organization, but ownership can be transferred to another member from Organization settings → Users. When you transfer ownership, you become an admin.
- Admin: can do everything the owner can do — invite users, create properties, edit feature flags, change funnels.
- Edit: read access to reports, can run AI Chat Analyst queries, can't change configuration.
Property-level access can be further restricted from Organization settings → Members. Adding a user to your org doesn't automatically grant access to every property — you pick which ones they can see.
Properties
Purpose
A property represents a single website or app. It owns its own analytics data, feature flags, funnels, custom events, audiences, and (for SaaS) subscription plans. The tracking script identifies which property an event belongs to via the property ID in window.badgerlyticsConfig.
Setup
To create a property:
- From your org dashboard, click + New property.
- Give it a name (e.g. "Store — Production"), the domain, and pick whether it's an ecommerce site, a SaaS product, or both.
- Copy the install snippet — or grab the SDK package — and drop it on your site.
- Visit your site once with debug mode on to confirm events are landing.
Considerations for environments
We strongly recommend using separate properties for production and non-production environments. Don't point your staging or local-dev script at the same property ID as production — you'll pollute your real reports with QA traffic and feature flags that were never meant for users.
A common setup is two properties:
- Production property: your live customers, real traffic only.
- Stage property: integration testing, internal QA, smoke tests, and most local development — point your dev build at the stage ID so QA traffic never hits production reports.
One snippet, two IDs
Syncing between properties
Because you usually want test/stage and production to behave identically, we provide a one-click sync for the things you tend to configure once and keep aligned:
- Feature flags — including iterations and variants
- Custom events — including their dashboard tokens
- Audience traits — labels, types, and tokens for segment rules
- Funnels — step names and orderings
Open the source property, find the resource you want to copy ("Copy to property…"), and pick the destination. For custom events, funnels, and SaaS plans, you can create a new copy or overwrite an existing one with the same identifier. Feature flags and audience traits are create-only: flags get a new token when the destination already has one; traits always copy under the same token and are blocked with an "already exists" message when the destination already has that token. Analytics data itself is never copied — each property owns its own events.
Analytics Reporting
Out of the box, every property gets the full suite of reports below. You don't need to wire each one up individually — installing the script and tracking your funnel covers most of them automatically.
Traffic
Where your visitors come from and how many of them there are. Top pages, referrer sources, UTM breakdowns, device and browser splits, country and region rollups, and trends over time. Useful for catching regressions in acquisition channels.
Engagement
How visitors actually use the site once they arrive. Session duration, bounce rates, pages per session, scroll depth, page performance (FCP, LCP, CLS), and event volumes broken out by page and device.
Revenue & Commerce
For ecommerce properties: conversion rate, revenue, top products, cart-abandonment rate (7-day locked abandonment window; conversions within 30 days still recover a streak), average order value, and a full funnel view from product listing → conversion. Joins purchase events back to the product views and cart updates that led to them.
SaaS
For SaaS properties: MRR, ARR, new MRR, expansion MRR, contraction, churned MRR, trial-to-paid conversion, plan distribution, and subscription-lifecycle reports. Powered by your conversion events plus the subscription lifecycle webhook (see SaaS reporting setup below).
A/B testing
Each running feature flag gets a dedicated results panel: visitor counts per variation, conversion rates, revenue per visitor, and statistical significance over time. You can pivot results by funnel step, audience trait, or device. Test results are also joinable with all the other reports — "revenue from variant v1 by device" is a real query you can answer in seconds.
Accuracy / sampling concerns
We don't sample. Every event you send is counted in our reports (browser events skip known bots in the tracker before send). On extremely high-volume properties some reports run on a slight delay during peak traffic, but the totals are accurate — not estimated.
Reports are built from events the tracking script sends from the visitor's browser. We don't estimate missing traffic on the server — if an event never leaves the client, it won't appear in your dashboard. That's different from sampling: we count every event we receive, but we can only receive what the browser is allowed to send.
- Conversion rates and revenue numbers are exact, not extrapolated.
- A/B test significance is computed on the full population, with standard frequentist confidence intervals.
- Privacy and browser settings — visitors who block cookies, use strict tracking protection, or run in private modes may not get a stable session, so page views and conversions can be undercounted compared with tools that use server-side measurement.
- Blockers and network conditions — ad blockers, corporate proxies, content-security policies, or a failed script load can prevent some or all events from reaching us.
- Consent and configuration — if you only initialize tracking after a cookie banner, traffic before consent won't be counted. Traffic filters and bot handling (see Common considerations) also intentionally drop some visits.
- If you notice a discrepancy with another tool, the most common cause is differences in what counts as a session or which events trigger conversions. Open those definitions side by side first — then check whether the gap could be client-side delivery (cookies, blockers, or consent).
Note
Consent and disabling the script
You are responsible for collecting consent under your jurisdiction and only turning on analytics when your policy allows it. The tracker exposes disconnect() and connect() on window.badgerlytics. While disconnected, the script can still load feature-flag config and keep assignments in cookies, but no analytics events are sent to our servers — including page views, conversions, and experiment impressions. Set disconnected: true in badgerlyticsConfig to start in that state, then call connect() when the visitor opts in.
window.badgerlyticsConfig = {propertyId: 'your-property-id',// Load the script for flags, but send no analytics until connect().disconnected: true,onLoad(tracker) {if (yourCmpHasAnalyticsConsent()) {tracker.connect();}},};// When the visitor accepts analytics later on the same page:// window.badgerlytics.connect();// When they reject or withdraw consent:// window.badgerlytics.disconnect();
Call disconnect() when consent is withdrawn. It also clears any events waiting in the outbound queue. Cookies are not removed automatically; if your policy requires it, clear them with removeCookie() on the same tracker instance (for example _bai_visitor and _bai_session).
Stricter option: do not include the script tag (or dynamic loader) until the visitor accepts. That avoids setting any Badgerlytics cookies before opt-in:
// Strictest option: do not download the script until opt-in (no cookies yet).function loadBadgerlytics() {if (document.getElementById('badgerlytics-tracker')) return;window.badgerlyticsConfig = { propertyId: 'your-property-id' };const script = document.createElement('script');script.id = 'badgerlytics-tracker';script.async = true;script.src = 'https://cdn.badgerlytics.com/scripts/badgerlytics.min.js';document.body.appendChild(script);}
SSR / middleware flag assignment (see SSR middleware (SDK)) can bucket users on the server without the browser script. If you use that for A/B tests, decide separately whether assignment cookies should wait for the same analytics consent as the tracker.
Heads up
Feature Flags
Overview
Feature flags in Badgerlytics serve two jobs: gating new features behind a switch, and running A/B tests with rigorous result tracking. Both share the same plumbing — a flag has variations, visitors get bucketed once, and every event we collect knows which variation each visitor saw.
Keep active flags lean
Types of feature flags
When you create a flag in the dashboard, you choose one of two types (the same labels as in the app):
- Experiment (A/B/C): compare two or more variants and measure lift vs
control— for hypothesis testing, significance, and choosing a winner. Variants usecontrol,v1,v2, … with custom traffic splits. - Feature flag (on/off): ramp a feature on or off with weights. Use
isFlagEnabled()in your app — not for classical A/B winner analysis. Two arms:control(off) andv1(on).
Test setup
Each test takes a minute or two to set up:
- Pick a flag key (e.g.
hero_test). This is the stable identifier you'll reference in code forever. - Define your variations and traffic split.
- For experiments only: optionally pick focus pages and/or restrict with an audience segment (e.g. logged-in users only). Feature flags (on/off) do not use these controls.
- Hit Start. Visitors begin getting bucketed immediately.
Code: Static / general
Client-side only — wait for badgerlytics:ready or use onFlagsReady before reading variations.
<script>// window.badgerlytics is ready once the script finishes loading.document.addEventListener('badgerlytics:ready', function () {var variation = window.badgerlytics.getVariation('hero_test');if (variation === 'v1') {document.body.classList.add('hero-test-v1');}});</script>
Code: React
Client-side hooks — no middleware. For SSR apps, see SSR middleware (SDK).
import { useVariation, useIsFlagEnabled } from 'badgerlytics-sdk/react';function Hero() {const variation = useVariation('hero_test', 'control');const newPricing = useIsFlagEnabled('new_pricing_table');return (<>{variation === 'v1' ? <HeroB /> : <HeroA />}{newPricing && <PricingV2 />}</>);}
Code: Next.js
Requires createBadgerlyticsMiddleware — full API (getAssignments, isFlagEnabled, traits, manual assignment) in SSR middleware (SDK) → Next.js.
// Pages Routerimport { getVariation } from 'badgerlytics-sdk/nextjs';export async function getServerSideProps({ req }) {return { props: { hero: getVariation(req, 'hero_test', 'control') } };}
// App Routerimport { headers } from 'next/headers';import { getVariation } from 'badgerlytics-sdk/nextjs';export default async function Page() {const h = await headers();const hero = getVariation(h, 'hero_test', 'control');return hero === 'v1' ? <HeroB /> : <HeroA />;}
Code: Remix
Pass the loader request (with forwarded x-bai-* headers) to getVariation. See SSR middleware (SDK) → Remix / React Router.
import { getVariation } from 'badgerlytics-sdk/remix';export async function loader({ request }) {return { hero: getVariation(request, 'hero_test', 'control') };}
Code: Astro
Read from Astro.locals, not request headers — see SSR middleware (SDK) → Astro.
---import { getVariationFromLocals } from 'badgerlytics-sdk/astro';const hero = getVariationFromLocals(Astro.locals, 'hero_test', 'control');---{hero === 'v1' ? <HeroB /> : <HeroA />}
Code: Nuxt
Read from event.context via getVariationFromEvent — see SSR middleware (SDK) → Nuxt.
import { getVariationFromEvent } from 'badgerlytics-sdk/nuxt';export default defineEventHandler((event) => {return { hero: getVariationFromEvent(event, 'hero_test', 'control') };});
Focus pages
Experiments only. Focus pages are an optional list of page paths used for per-page analytics on a test — the Focus Page Analysis report and AI insights for that experiment. Each configured path gets pageviews, sessions, bounce, and engagement broken out by variation. Leave the list empty if you only need site-wide test results.
Each entry must be an exact path starting with / (for example /checkout or /products/widget). Wildcards and regex are not supported. You can add up to 10 paths per experiment.
More than 10 URLs?
Audiences
Experiments only. Pair an experiment with an audience segment to limit bucketing to a slice of users. Common examples:
- Returning visitors only
- Users on the "pro" plan
- Mobile users only
- Anyone who has previously triggered a custom event
Audience traits can be set from your code via setTraits() (see Audiences below) or inferred from behavior and request context.
Iterations
Sometimes a test isn't conclusive and you want to try a tweaked version. Iterations let you re-bucket users and start a fresh run on the same flag without breaking the old data. Each iteration is reported separately, so you keep the full audit trail of what you tried, when, and how it performed.
Note
(visitorId, flagKey, iteration) as the seed, so a user assigned to variant v1 in iteration 1 might end up in controlin iteration 2. That's by design — old assignments shouldn't bias a new run.Starting and stopping tests
Use the Start button on the flag detail page when you're ready to go live. Use Stop to freeze bucketing. Stopping a test keeps the historical data intact — the flag still exists, the results panel still works, you just stop assigning new visitors. From there you can:
- Ship the winner — set the flag to always-return-that-variation while you remove the code path.
- Iterate — start a new iteration with adjusted variants.
- Archive — keep the results around but hide the flag from the main list.
Custom Events
Purpose
Custom events let you track anything specific to your product that isn't covered by the built-in events. Common examples: newsletter signups, content downloads, button clicks on a special landing page, integrations enabled, video plays.
Custom events show up in their own report, can be used as funnel steps, can trigger audience membership, and can be the conversion target for A/B tests.
Setup
To use a custom event, you need to register it once:
- Open Events / Funnel / Audience setup → Custom events.
- Click + New event and give it a token in the form
custom_my_event(lowercase, thecustom_prefix is required). - Save. Tokens propagate to the property CDN immediately, so the tracker will accept the event on your very next page load.
Heads up
cusotm_signup from silently fragmenting your data.Copying events to another property
When stage and production should track the same custom events, use Copy to property… on an event row under Events / Funnel / Audience setup → Custom events. Pick a destination property in your organization.
You can create a copy or overwrite an existing event on the destination:
- Create copy — Adds a new event with the same display name and metadata schema. Uses the same token when the destination does not have it yet; otherwise we append a numeric suffix (for example
custom_signup_2). - Overwrite — Updates the destination event that already has the same token: display name and metadata schema are replaced. Payload fields not declared in the source schema are dropped at ingestion.
Syncing stage and prod
Code examples
// Register the event in the dashboard first:// Custom events → New event → token: custom_newsletter_signupwindow.badgerlytics.trackCustomEvent('custom_newsletter_signup', {source: 'footer',utm_campaign: 'spring_sale',});
Querying in reports
After events are flowing, open Reports for the property and choose the Engagement tab. The Custom Events report is the main place to explore event volume, how often visits trigger each event, and (when relevant) revenue or conversions tied to sessions that fired the event.
- Date range — Use the range control at the top (last 7/30/90 days, or a custom start/end). Toggle Compare to previous to see the same metrics for the prior period side by side (this report turns compare on by default). Use Daily breakdown for a day-by-day chart instead; compare and daily cannot run at the same time.
- Event — Leave the event picker unset to see one row per registered custom event. Pick a single event to focus the table and unlock deeper breakdowns.
- Group by — Fan out rows beyond the default rollup:
A/B flagsplits counts and rates by test, variation, and iteration;Page pathshows where a chosen event fired;Metadata: …options appear for keys you declared on that event under Events / Funnel / Audience setup → Custom events (page path and metadata breakdowns require selecting an event first). - A/B test filter — Optionally narrow to one running or stopped experiment and variation to read event rates in the context of a specific test (lift and significance columns appear when a control arm exists).
- Other filters — Use the filter panel for visitor type (new vs returning) or, when supported, a specific page path. Click Apply filters after changing them.
On the same Engagement tab, Conversion Funnel reports step-by-step drop-off for funnels you define under Events / Funnel / Audience setup — use that when the question is "where do people leave the journey?" rather than raw event counts. Experiment results on the A/B Testing tab can also use a custom event as the conversion goal when you set one on the flag.
Note
source or plan) when you create or edit the event.Considerations
- Keep metadata small — a handful of key/value pairs, not a JSON blob. We index the keys for filtering in reports.
- Avoid putting PII in metadata. Use anonymous identifiers and traits instead.
- One event per real-world action. Two near-identical tokens with different metadata are usually a sign you wanted one event with a metadata field.
Funnels
Purpose
A funnel is an ordered list of steps a visitor takes to complete a goal — the most common being product listing → product page → add to cart → checkout → purchase. Badgerlytics' funnel report shows how many users reached each step, the drop-off between steps, and conversion rate end to end.
Setup
To wire up a funnel:
- Open Events / Funnel / Audience setup → Funnels and click + New funnel.
- Give it a token (e.g.
purchase) and define its steps — number, name, and (optionally) a description. - Save. Each step is now a valid argument for
trackFunnelStep()from your code.
One funnel, many places
Copying funnels to another property
To align stage and production funnel definitions, use Copy to property… on a funnel row under Events / Funnel / Audience setup → Funnels. Pick a destination property in your organization.
You can create a copy or overwrite the destination's active funnel for the same token:
- Create copy — Inserts a new funnel with the same name and steps. Uses the same token when the destination does not have it yet; if that token is already taken (including inactive versions), we assign a suffixed token instead.
- Overwrite — Replaces the destination's active funnel for that token: the current version is deactivated (kept for historical reports) and a new version is created from this funnel's name and steps.
Syncing stage and prod
Code examples
A small helper module keeps your call sites tidy and your step numbers in one place:
const FUNNEL_NAME = 'purchase';export const FUNNEL_STEPS = Object.freeze({PRODUCT_LISTING_PAGE: { number: 1, name: 'product_listing_page' },PRODUCT_DISPLAY_PAGE: { number: 2, name: 'product_display_page' },ADD_TO_CART: { number: 3, name: 'add_to_cart' },VIEW_CART: { number: 4, name: 'view_cart' },PAYMENT_AND_REVIEW: { number: 5, name: 'payment_and_review' },CONVERSION: { number: 6, name: 'conversion' },});export function trackFunnelStep(step) {if (!step || step.number == null || !step.name) return;window.badgerlytics.trackFunnelStep(FUNNEL_NAME, step.number, step.name);}
Then use it where each step actually happens:
// In a product detail componentuseEffect(() => {trackFunnelStep(FUNNEL_STEPS.PRODUCT_DISPLAY_PAGE);}, []);// In your "add to cart" handlerfunction addToCart(product) {cart.add(product);trackFunnelStep(FUNNEL_STEPS.ADD_TO_CART);}
Considerations
- Step numbers don't have to be consecutive in code, but the report treats them as ordered. Don't reorder them after you've started collecting data — pick the order once.
- Visitors can skip steps (a customer landing on a deep link goes straight to step 2). The funnel report handles this naturally.
- Fire each step exactly once per session per page where it makes sense. Don't fire
add_to_carton every re-render — fire it on the actual click handler.
Audiences
Purpose
An audience is a named group of visitors who share traits or behavior. Use them to:
- Filter reports ("revenue from logged-in users only").
- Target feature flags ("run the pricing test on free-tier users").
- Power personalization ("show this promo to returning visitors from the UK").
Setup
Audiences combine traits and behavior to define who belongs in a group. On feature flags, use the Audience segment editor to target experiments (for example "logged-in pro users only").
Custom traits must be registered on the property before you can reference them in segment rules or set them from your app. Open Events / Funnel / Audience setup → Audience, add a label and type (boolean, string, or number), and save — we generate the token from the label (for example "Logged in" → logged_in). Built-in traits such as visitor_type, device_type, and UTM fields are always available and do not need to be registered.
Segment rules can combine:
- Trait conditions:
signed_in = true,plan = pro, etc. - Behavior conditions: has triggered a specific event in the last N days; reached a particular funnel step; viewed a specific page.
- Context conditions: country, device, referrer source.
Copying traits to another property
When stage and production should share the same trait registry, use Copy to property… on a trait row under Events / Funnel / Audience setup → Audience. Pick the destination property in your organization; we copy the label, type, and token as-is.
Audience trait copy is create-only — there is no overwrite option. If the destination already has a trait with that token, the modal tells you it already exists and you cannot copy until you remove the conflicting trait or choose a different destination. Register traits on the destination before copying feature flags whose segment rules reference them.
Syncing stage and prod
Code examples
Traits are how you tell us what an anonymous visitor is — without identifying them. Set them whenever the user's state changes (login, logout, plan change):
// On loginwindow.badgerlytics.setTraits({signed_in: true,plan: 'pro',account_age_days: 124,});// On logoutwindow.badgerlytics.setTraits({signed_in: false,plan: null,});
For SSR, set the _bai_traits cookie on login/logout so segment rules apply on the first render after sign-in. Use setTraitsOnResponse (Next.js, Remix, Astro API routes) or setTraitsOnEvent (Nuxt). Full examples per framework are in SSR middleware (SDK) under each stack's section.
SSR vs client-only
badgerlytics-sdk/react exposes useSetTraits() for the browser only; traits take effect on the next page load unless you also set _bai_traits on the server.No PII in traits
plan, tenure_bucket) and you stay safely anonymous.Considerations
- Audiences are evaluated live whenever an event fires. New audience definitions retroactively apply to existing data for reporting, but only affect new flag assignments going forward.
- Keep audience names descriptive — "logged-in pro users" reads better than "cohort_3" six months from now.
- For SaaS apps, the most useful audiences are usually based on subscription state (trialing, active, past_due) — these change as lifecycle events come in via webhook.
Analytics Data Retention
Policy
We retain your analytics data for the period included with your plan (30 days on Starter, 13 months on Growth, 25 months on Scale). Rolled-up summaries used in long-range trend charts are kept longer, but individual event-level data ages out on the retention schedule.
- Account and billing data (invoices, plan changes) is retained for seven years for tax and compliance reasons.
- Audit logs (who changed what flag, who deleted what funnel) are retained for the lifetime of your account.
Deleting property data
You can request a full deletion of a property's analytics data at any time from Property settings → Data retention → Delete property data. We confirm with a typed property name, then run the deletion asynchronously. Live reports update as the deletion progresses; a usually-completes-within-24-hours indicator stays on the page until it's done.
Heads up
Account cancellation
When you cancel your account, your data is retained for 30 days in case you want to come back, then permanently deleted. You can download a CSV export of your reports up to the moment of cancellation from the dashboard.
AI Session Replay
Overview
AI Session Replay captures how real visitors move through your site, then lets you watch those visits back and run AI analysis on them. It's the fastest way to see where people hesitate, get stuck, or abandon — without guessing from aggregate charts alone.
Recordings are tied to the same analytics context you already have: device type, pages visited, custom events, A/B test assignments, and whether the visitor converted. That makes it easy to find the sessions that matter and understand what actually happened.
Plan availability
Getting started
- Open AI Session Replay from the property sidebar.
- Turn on Session recording for the property. Changes reach your live site within a few seconds.
- Make sure the Badgerlytics tracking script is installed — recordings only flow once the script is active on your site.
- As engaged visitors browse, sessions appear in the list. Select one to see details, press play to watch the replay, or run AI analysis when you want a written breakdown.
What you get in the panel: a scrollable list of recent sessions with device, duration, page count, conversion status, and a tab count when the visit spanned multiple browser tabs. The replay player supports play/pause and scrubbing; multi-tab visits include a tab strip so you can switch between tabs without losing context.
Only enable on production
Filtering sessions
Use the filter bar above the session list to narrow down to the visits you care about. Filters can be combined — for example, mobile visitors who converted during a specific A/B test.
- Device — desktop, mobile, or tablet.
- Conversion — converted or not converted (based on your property's conversion definition).
- Visitor — new or returning.
- Test & variation — filter to sessions that saw a running A/B test, optionally narrowed to a single variation.
- Custom event — show only sessions where a specific custom event fired.
Clear filters at any time to return to the full list. If nothing matches, try widening your criteria — only sessions from the last fourteen days are kept (see Limits below).
AI analysis
For any completed session, you can run Analyze session to get an AI-written review of that specific visit. Analysis typically finishes in under a minute. Each session can be analyzed once; results are saved so you can reopen them later.
The report includes:
- Summary — a concise narrative of what the visitor did and how the experience felt overall.
- Issues — friction points or moments of confusion observed during the journey.
- Opportunities — openings to improve conversion or engagement based on how the visitor behaved.
- Recommendations — concrete, testable changes worth trying — often good candidates for your next A/B test.
For multi-tab sessions, the analysis considers the full cross-tab journey — including when the visitor switched between tabs — not just a single page in isolation.
AI analysis draws from your plan's monthly AI token allowance, the same pool used by AI Insights, Chat Analyst, and UX Analyst.
Limits & retention
Session replay is designed for high-traffic sites without storing every single visit. Here's how capture and retention work:
- 14-day retention — recordings are stored for fourteen days, then removed automatically. Download or review anything you need to keep within that window.
- Daily cap — up to 2,000 sessions per property per day (UTC). After the daily cap is reached, new sessions are not recorded until the next day.
- Sampling — the first 150 engaged sessions each day are always recorded. After that, an additional 20% of eligible sessions are sampled. High-traffic properties still get a steady stream of replays without storing every visit.
- Bounces excluded — single-page visits with no meaningful engagement are not stored. Only sessions where the visitor actually engaged (for example, viewing more than one page or interacting with the site) are captured.
- One-hour maximum — an individual recording stops after one hour of active capture. Very long visits are truncated at that point.
- Multi-tab sessions — when a visitor has several tabs open at once, each tab is captured as its own recording. They appear together as one session in the list and in the replay player, where you can switch between tabs. A multi-tab visit counts as one session toward your daily recording limit.
Privacy & masking
Session replay is built with privacy in mind. Sensitive data is masked before it ever leaves the visitor's browser:
- Form fields — all input values are masked automatically. The replay still shows that someone typed in a field, but the actual characters are never stored.
- Editable content — text in contenteditable regions is masked by default.
- Custom selectors — add CSS selectors in the Privacy masking panel to hide additional regions on your site (account numbers, internal IDs, support chat widgets, and so on). Masked text appears obscured in the replay rather than as readable copy.
Review your masking rules after enabling recording, especially on pages that display user-specific content. Changes save to the property and apply to new recordings within a few seconds.
Heads up
AI UX Analyst (Beta)
Overview
UX Analyst is an AI assistant that visits your site, evaluates its user experience against a set of best-practice heuristics, and writes up specific, actionable recommendations. Think of it as a thoughtful second pair of eyes that's read every UX paper of the last decade.
Plan availability
Beta feature
Usage instructions
- Open UX Analyst from the property sidebar and click New mission.
- Pick the focus: full site, a specific funnel, a single page, or a custom URL list.
- Optionally add a goal in your own words ("help us reduce cart abandonment", "evaluate our pricing page for clarity").
- Hit run. Missions typically take 5–20 minutes depending on how many pages you've included.
- When the report is ready, you'll see a list of findings with screenshots, severity, and suggested next steps — many of which include suggested A/B tests you can launch with a click.
Limitations
- Logged-in flows aren't supported yet. UX Analyst sees what an anonymous visitor sees. (Use a demo-mode URL if you need authenticated content reviewed.)
- Pages behind hard paywalls, captchas, or geo-blocks may not be reachable.
- The analyst can't test interactive flows that require real customer data (e.g. placing a paid order). It evaluates the static UX and information design.
- Recommendations are guidance, not gospel. Always validate with an A/B test before declaring victory.
Billing & Usage
Pricing and usage billing
Plans bundle a monthly base price plus two metered allowances — sessions (visitor sessions ingested across all your properties) and AI tokens (consumed by AI Insights, Chat Analyst, and the UX Analyst). When you exceed an included allowance, the overage is billed at the per-unit rate published on your plan's pricing card.
- Sessions are counted from ingested events. Browser traffic is filtered by bot checks in the tracker before send; server API traffic is not. A session is a continuous run of activity from a single visitor; idle for ~30 minutes ends it.
- AI tokens map roughly to the LLM tokens the AI features consume on your behalf. AI Insights, Chat Analyst, and UX Analyst all draw from the same pool.
- If you consistently exceed your allowance, moving up a tier is usually cheaper than paying overage rates — the included allowance jumps quickly between tiers and overage rates drop.
Token usage
AI Insights, Chat Analyst, the UX Analyst, and the AI Session Recorder all draw AI tokens from the same monthly allowance. How quickly each one burns through that pool depends on how it's triggered and how much context it works with. The table below is a rough guide to what to expect.
| Feature | Usage level | Activation | Notes |
|---|---|---|---|
| AI Insights | High | Automatic — daily / every other day, per plan | Shouldn't exhaust your usage allotment on its own. |
| AI Chat | Medium | On demand | Depends on the number of chats and the context length of each chat. Clear long-running conversations frequently. |
| AI UX Analyst | Low–Medium | On demand | Individual missions are medium usage, but most users only run a couple of these sporadically. |
| AI Session Recorder | Medium | On demand | Depends on how many sessions you choose to analyze. |
Trial period
All paid plans start with a 7-day free trial. You get the full features of whichever plan you signed up to try — Chat Analyst, AI Insights, UX Analyst (where the plan includes it) — for the full week.
At the end of the trial, you will be charged the full base price for the plan you signed up to try.
Refund policy
We don't offer refunds, but you can cancel any time and you'll keep access through the end of your current billing period. There's no contract — pay month to month, stop whenever you want.
Account usage
The Usage tab in your organization settings shows:
- Sessions ingested this billing period vs. included allowance
- AI tokens used vs. included allowance
- Properties in use vs. plan limit
- Running A/B tests vs. plan limit
- Projected overage cost at the current rate-of-burn
Cancellation
Cancel from the Manage Plans page. Confirm the typed organization name, and you're done. Your service continues until the current billing period ends. After that the account pauses, and we delete your data 30 days later (you can export reports as CSV before that).
Note
Enterprise plan with custom session and token allowances, volume overage discounts, and priority AI queues? Get in touch from the pricing page.Changelog
Coming soon.