Behavioral analytics & funnels
Metering tells you how much your users consume. Behavioral analytics tells you what they do on the way there - paywall views, onboarding steps, checkout - so you can see where activation and conversion break down. This guide goes from the first capture() call to a working funnel in the dashboard.
track / reserve. It writes to its own tables and is browser-safe with a public pk_* key. Method reference: capture(), identify(), alias().1. The hybrid model - anonymous pre-login, identified post-login
APL Analytics runs in hybrid mode by default. The rule that matters: in any capture() call, whether you pass distinctId decides what kind of event APL records.
- Pre-login visitors - call
capture()with nodistinctId. The event lands inanalytics_anonymous_eventswith no person profile, no stored IP, and no browser identifier. A salted, 24h-scoped hash dedupes visitors within one day. - Post-login users - pass your real user id as
distinctId. Events are linked to a person profile inanalytics_events.
'use client';
import { createClient } from '@vevee/sdk';
// pk_live_* / pk_test_* - safe to ship in the browser bundle.
const vevee = createClient({ apiKey: process.env.NEXT_PUBLIC_VEVEE_KEY! });
// PRE-LOGIN - anonymous aggregate. No cookie banner needed in the EU.
vevee.analytics.capture({ event: 'paywall_shown', properties: { placement: 'home_hero' } });
// POST-LOGIN - identified, linked to a person profile.
vevee.analytics.capture({
distinctId: user.id,
event: 'checkout_completed',
properties: { plan: 'pro', amountCents: 1900, currency: 'USD' },
});localStorage / sessionStorage / cookies, the raw IP and full User-Agent are discarded server-side, and the daily session hash rotates so cross-day correlation is impossible by design. See the Privacy & GDPR guide for the legal reasoning and the few cases that do need a banner.2. Properties & person profiles
properties are free-form key/value pairs (string, number, boolean, null) you can later break a funnel down by. Two reserved keys update the long-lived person profile instead - they only land on identified events:
vevee.analytics.capture({
distinctId: user.id,
event: 'subscription_started',
properties: {
plan: 'pro', // event property
$set: { plan: 'pro', last_seen_at: nowIso }, // overwrite profile
$set_once: { first_paid_at: nowIso }, // first-write-wins
},
});Profile updates land on the person row and apply to every event by that person going forward. Full reference: Person profiles.
3. Anonymous → identified (consent-gated)
Two ways to bring an end-user into the identified universe:
- Stop being anonymous from now on - call
capture()with the real user id from now on, andidentify()to write profile properties. Pre-login anonymous events stay inanalytics_anonymous_eventsas aggregate signal - they are not retroactively attributed to the new account. No consent needed: no two universes are stitched. - Stitch the pre-login session to the new account - this is the consent-gated path. Acquire explicit consent through your cookie banner, then call
identify()withmergeAnonymousId+consentGiven: true:
await vevee.analytics.identify(
user.id,
{ email: user.email, plan: 'pro' },
{ signup_source: 'twitter' },
{ mergeAnonymousId: anonId, consentGiven: true },
);The server records the consent in consent_audit_log as a 5-year evidence record. Without consentGiven: true the SDK throws consent_required. Same gate applies to alias() when either id is an APL anonymous session (anon_ prefix).
hybrid mode has no APL-minted anonymous id to merge. The SDK never writes to localStorage on its own. Use mergeAnonymousId only when you opt into identified mode (and accept the cookie-banner requirement), or when you mint your own anonymous id with consent already in hand.4. Use reserved event names
Any event name works, but Vevee ships a catalogue of reserved names for the AI-app lifecycle - signed_up, onboarding_started, onboarding_step, onboarding_completed, paywall_shown, paywall_clicked, checkout_started, checkout_completed, trial_started, subscription_started, and more. Reserved events get badges, preset funnels, and conventional-property hints in the dashboard.
import { RESERVED_EVENTS, isReservedEvent } from '@vevee/sdk';
isReservedEvent('checkout_completed'); // true - gets first-class treatment
isReservedEvent('clicked_my_button'); // false - still captured, just generic5. Build a funnel
Funnels are built in the dashboard, not the SDK - there is no funnel API to call. Open your app, go to Funnels → New funnel, and add an ordered list of steps. Each step is an event (or an OR of several events); a step can be marked optional. The funnel reads the events you captured and shows, for each step, how many people reached it and how many dropped off.
paywall_shown → signed_up → onboarding_completed → feature_used → checkout_completed. Set a conversion window (how long a person has to complete the funnel) and an optional breakdown property (e.g. placement or plan) to split conversion by segment.Anonymous events count toward the pre-signup steps of a funnel as aggregate signal; identified events drive the post-signup steps and any per-person retention view.
Filter the cohort by attributes
Add a personFilters entry to any funnel to restrict the cohort to persons matching a declared attribute. Filters apply BEFORE the step walk, so non-matching persons never enter the funnel.
{
"name": "Onboarding completion - teachers only",
"personFilters": [
{ "attribute": "persona", "op": "eq", "value": "teacher" }
],
"steps": [
{ "events": [{ "kind": "event", "name": "signed_up" }] },
{ "events": [{ "kind": "event", "name": "onboarding_completed" }] }
],
"conversionWindowHours": 24,
"range": { "kind": "last_7_days" }
}Operators: eq, neq, in. Multiple filters AND together. Anonymous (cookieless) persons have no attribute storage and are excluded by any personFilters entry. See Attributes for how to declare and set them.
Multiple keys in a step’s event filters also AND together. A step like { filters: [{ property: 'step', op: 'eq', value: 'persona' }, { property: 'value', op: 'eq', value: 'teacher' }] } requires BOTH properties to be present on the same event before the step is counted.
Step value breakdown
Set breakdownProperty on any step to see the distribution of a chosen event property across the users who reached that step. The funnel result returns the top buckets ordered by user count plus an (other) tail, so you can answer questions like which persona did people pick at the onboarding step they converted on? without running a separate query. Limit defaults to 10 buckets (max 50); events missing the property roll into a (missing) bucket.
{
steps: [
{
events: [{ name: 'onboarding_step_answered', filters: [{ property: 'step', op: 'eq', value: 'persona' }] }],
breakdownProperty: 'value', // surfaces what users picked
},
// ...next steps
],
}6. Other modes - when to switch
Set the mode once at createClient:
const vevee = createClient({
apiKey: process.env['VEVEE_KEY']!,
analytics: {
mode: 'hybrid', // 'hybrid' (default) | 'identified' | 'aggregate'
requireConsent: true, // default
},
});| Mode | What it does | Reach for it when… |
|---|---|---|
hybrid | Anonymous aggregate pre-login, identified post-login. No browser storage. | You want EU-friendly analytics without a banner and per-user metrics post-signup. Default. |
identified | Every visitor gets a persistent anon_… id via getAnonymousId() and localStorage. | You need full first-touch attribution from before signup, and you already run a CMP / cookie banner. |
aggregate | Always anonymous. capture() silently drops any distinctId; identify() / alias() are disabled. | Top-line metrics are enough, and you want zero per-user processing. |
7. Batching & quotas
To flush a client-side queue, send up to 100 events at once with captureBatch(). Anonymous and identified events can be mixed in the same batch - just omit distinctId on the anonymous ones. Analytics events count against a per-workspace monthly quota that is separate from metering; exceeding it returns analytics_quota_exceeded (429). Test-mode events are bounded by the sandbox quota instead.
8. Privacy gates
capture() for a distinctId whose person has been opted out or is pending erasure returns a normal-looking response but writes nothing. Wire optOut / deletePerson / exportPersonto your app’s privacy settings - the Privacy & GDPR guide walks the integration end to end.
Where to next
- capture() - record a behavioral event.
- identify() - identify a user; consent-gated merge.
- alias() - bridge two ids for one person.
- optOut() · deletePerson() · exportPerson() - GDPR rights.
- Privacy & GDPR- the legal & integration story.
- Attributes - declared, typed facts on persons; used in funnel cohort filters and the people list.
- track() - the metering surface, for AI-usage accounting.
- Errors & status codes - every error code and how to handle it.