From Tracking Script to SDK
Map Convert UI audience, location, and goal concepts to SDK calls
When you use a Convert SDK directly instead of the classic Convert tracking script, the SDK becomes the runtime engine for the rules and goals you configured in the Convert UI — but you become the trigger and context provider. Nothing auto-detects URLs, clicks, geolocation, or visitor state for you. You provide them on every call and you decide when each conversion fires.
This page maps Convert UI concepts (goal types, audience rules, location rules, experience types) to the SDK calls and parameters you'd use to replicate them. The mapping is the same across every Convert SDK — JavaScript, PHP, and Ruby today, and the same shape will apply to future SDKs (iOS, Android, etc.) when they ship. Code snippets below are shown side-by-side in JavaScript, PHP, and Ruby where the trigger surface allows; browser-only patterns are explicitly marked.
See also: Server-Side Experimentation, Running Experiences, Tracking Conversions, Rule Evaluation.
The Core Shift
The classic tracking script is opinionated and turnkey. It:
- Reads the URL and refires evaluation on
history.pushState/popstate. - Reads the User-Agent and infers browser, OS, and device.
- Reads cookies (geo, UTM, custom).
- Watches the DOM for click and form-submission targets matching your goal selectors.
- Auto-fires
viewExpevents when the visitor enters a matching site area. - Auto-fires
hitGoalevents when a matching trigger condition occurs. - Persists visitor identity across pages via
_conv_v.
No SDK does any of those things — by design. Each SDK exposes two surfaces and trusts you with the rest:
runExperience/runExperiences— given the context you supply, returns the bucketed variation (and fires an exposure event).trackConversion— given a goal key and any attributes you supply, fires a conversion event.
Everything between (URL change detection, click watching, geo lookup, cookie parsing, consent gating) is your job. The pages below tell you what attribute keys the SDK expects so the rules you configured in the UI still match. The keys are SDK-agnostic — they're properties of the project config you wrote in the Convert UI, not language-specific.
1. Experience Targeting
When you run userContext.runExperience('experience-key') (or runExperiences() to evaluate all active experiences), the SDK walks the experience's locations and audiences rule trees. For each rule, it looks up a key in the attribute object you passed and runs a comparison. If you don't pass that key, the rule can't match and the visitor isn't bucketed.
The SDK accepts two attribute objects on every runExperience(s) call:
| Attribute | What the SDK reads from it | Typical keys |
|---|---|---|
locationProperties | Anything tied to where the visitor is — URL parts, referrer, page path, hostname, hash, querystring components. | url, path, referrer, hostname, hash, query, etc. |
visitorProperties | Anything tied to who the visitor is — geo, device, browser, OS, visitor type, UTM tags, custom dimensions, cookies, logged-in state, plan tier, anything you want to target on. | country, region, device, browser, os, visitorType, utm_source, utm_medium, plan, isLoggedIn, etc. |
const variation = userContext.runExperience('homepage-hero', {
locationProperties: {
url: window.location.href,
path: window.location.pathname,
referrer: document.referrer
},
visitorProperties: {
country: 'US',
device: 'desktop',
browser: 'chrome',
visitorType: 'returning',
utm_source: new URLSearchParams(location.search).get('utm_source') ?? undefined
}
});use OpenAPI\Client\BucketingAttributes;
$variation = $context->runExperience('homepage-hero', new BucketingAttributes([
'locationProperties' => [
'url' => $request->fullUrl(),
'path' => $request->path(),
'referrer' => $request->header('referer'),
],
'visitorProperties' => [
'country' => $geo->country,
'device' => $device,
'browser' => $browser,
'visitorType' => $isReturning ? 'returning' : 'new',
'utm_source' => $request->query('utm_source'),
],
]));# Visitor properties are flat top-level keys; `location_properties` is the
# reserved nested key. Keys are matched verbatim against your configured rule keys.
variation = context.run_experience("homepage-hero", {
country: geo.country,
device: device,
browser: browser,
visitorType: returning? ? "returning" : "new",
utm_source: params[:utm_source],
location_properties: {
url: request.original_url,
path: request.path,
referrer: request.referer
}
})# Per-call overlays are keyword-only: audiences via attributes=, locations via
# location_attributes=. Keys are matched verbatim against your configured rule keys.
variation = context.run_experience(
"homepage-hero",
attributes={
"country": geo.country,
"device": device,
"browser": browser,
"visitorType": "returning" if returning else "new",
"utm_source": params.get("utm_source"),
},
location_attributes={
"url": request.url,
"path": request.path,
"referrer": request.referrer,
},
)The exact keys you need depend on the rules you configured. Inspect your project's experiences[].audiences and experiences[].locations / experiences[].site_area to see which keys each rule references, and make sure your visitorProperties / locationProperties include those keys with the right values.
Common rule-type translations
These are the most common UI rule types and what to pass. If your rule references a key not listed here, just match the key name your rule uses.
| UI rule | Pass via | Typical value source |
|---|---|---|
| URL (exact / contains / regex / starts-with / ends-with) | locationProperties.url (or path, depending on rule config) | window.location.href or your server-side request URL |
| Hostname | locationProperties.hostname | window.location.hostname |
| Query parameter | locationProperties.query.<param> or split out at the top level | parse window.location.search |
| Referrer | locationProperties.referrer | document.referrer |
| Country | visitorProperties.country | Geo-IP from your CDN/edge headers (cf-ipcountry, x-vercel-ip-country, oxygen-sub-country) or a server-side geo service |
| Region / state | visitorProperties.region | Same source as country |
| City | visitorProperties.city | Same source as country |
| Device type | visitorProperties.device ('desktop' / 'mobile' / 'tablet') | UA parser (browser or server-side) |
| Browser | visitorProperties.browser (e.g. 'chrome', 'safari', 'firefox') | UA parser |
| Operating system | visitorProperties.os (e.g. 'windows', 'macos', 'ios', 'android') | UA parser |
| Visitor type (new / returning) | visitorProperties.visitorType | Check your own visitor-ID cookie: present → 'returning', absent → 'new' |
| UTM tag | visitorProperties.utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid | Parse window.location.search on landing, persist to a cookie for later evaluation |
| Cookie value | visitorProperties.<cookieName> | Read the cookie yourself |
| Logged-in state | visitorProperties.isLoggedIn (boolean) | Your auth state |
| Plan / tier / role | visitorProperties.plan, visitorProperties.role, etc. | Your application state |
| Custom segment | Use runCustomSegments(['segment-key'], { ...properties }) before bucketing — see Visitor Context | n/a |
If your rule uses a value the SDK couldn't evaluate (because the key wasn't in the attributes you passed), the SDK returns a need-more-data RuleError (RuleError.NEED_MORE_DATA in JS, RuleError::NeedMoreData in PHP, RuleError::NEED_MORE_DATA in Ruby) — not a false match. (The Python SDK instead returns None and surfaces the reason through context.diagnose_experience(key), e.g. DiagnosticReason.AUDIENCE_MISMATCH.) That's how you tell "the visitor genuinely didn't qualify" from "you forgot to pass the key". The SDK's debug logs surface which keys were missing — see the Troubleshooting page.
URL parsing detail
URL rules in the Convert UI typically operate on a normalized URL string. Most stacks pass locationProperties.url as the full URL (window.location.href in the browser; the equivalent server-side value otherwise). Some rule configs target specific parts (path only, hostname, querystring); in those cases, pass the corresponding sub-key (path, hostname, query) so the rule has what it needs.
If your rules were written against window.location.href, pass window.location.href. If they were written against window.location.pathname, pass that. The SDK doesn't normalize for you — what's in the rule is what gets compared.
2. Experience Types
Fullstack projects support two experience types. Both use the same runExperience / runExperiences calls.
type field on ConfigExperience | What it does | SDK pattern |
|---|---|---|
a/b_fullstack | Standard A/B test. Visitor is bucketed into one variation; you render or apply that variation. Goal hits attribute to the bucketed variation. | runExperience('exp-key') → check variation.key and render accordingly. |
feature_rollout | Feature flag rollout. Variations represent feature states (enabled/disabled, or per-variant variable values). Goals are typically tied to the feature's effect rather than a variant comparison. | runFeature('feature-key') → check feature.status ('enabled' / 'disabled') and read feature.variables. See Running Features. |
You can run both kinds in the same project. A/B tests use experiment-style reporting; feature rollouts use feature-status reporting.
3. Goal Triggers
In the Convert UI, goals carry conceptual trigger types — "this goal fires when the user clicks a button", "this goal fires when the user reaches /thank-you", and so on. The classic tracking script auto-watches for each trigger type.
In any SDK, all of that is your code's responsibility. There is one method — trackConversion(goalKey, attrs?) — and it fires whenever you call it. The goal's key is the only thing the SDK cares about; everything else (selector watching, URL pattern matching, timing) is the customer's trigger code.
Below are the common goal types you'd find in the UI and one-or-two patterns for replicating each. Each goal type can fire from any SDK — what changes is the surface where the trigger event happens. Where the event has both a browser and a server-side surface (e.g. URL visit → route handler, form submit → POST handler), examples are shown in both JavaScript and PHP. Some triggers are inherently single-surface (clicks are DOM-only; engagement metrics are client-only) and are marked as such, with notes on how future mobile SDKs would express the same idea.
About goal
rules: a goal in your project config may have arulesobject attached. When you calltrackConversion(key, { ruleData: {...} }), the SDK evaluates those rules against yourruleDataand only fires the conversion if they match. If the goal has no rules, it always fires when you call. Most "trigger" logic lives in your code, not in the goal config —ruleDatais for fine-grained conditional firing, not full trigger detection.
Click goal — element click
Browser-only trigger. A click is a DOM event with no native server-side analogue — the closest server-side equivalent is the action the click eventually causes (form POST, API call), in which case you'd model the goal as a form-submit or URL-visit goal instead.
UI config: a CSS selector. The classic tracking script auto-attaches a delegated click listener and calls hitGoal when a matching element is clicked.
JavaScript SDK pattern — attach the listener yourself:
document.body.addEventListener('click', (e) => {
if (e.target.closest('.signup-cta')) {
userContext.trackConversion('signup-cta-click');
}
});Delegated to document.body so it survives DOM mutations and SPA re-renders. If your variation code adds and removes the target element repeatedly, the delegated listener still works — no need to re-attach. For dynamic selectors or selector lists, replace .closest('.signup-cta') with whatever query matches your goal's selector spec.
Future mobile SDKs: the same goal-key conversion would fire from a native gesture handler (
UITapGestureRecognizeron iOS,View.OnClickListeneron Android). The SDK call shape is the same —trackConversion('signup-cta-click')— only the trigger source changes.
URL-visit goal — visitor reaches a specific path
Browser and server both. UI config: a URL pattern. The classic tracking script fires when the visitor lands on a matching page.
Browser pattern — detect navigation and check the path. Initial-load case is easy; SPA case needs hooking the router:
function maybeFireUrlGoal() {
if (window.location.pathname === '/thank-you') {
userContext.trackConversion('purchase-confirmation-reached');
}
}
// Initial load
maybeFireUrlGoal();
// SPA: hook your router. Vanilla fallback:
window.addEventListener('popstate', maybeFireUrlGoal);
const origPush = history.pushState;
history.pushState = function (...args) {
origPush.apply(this, args);
maybeFireUrlGoal();
};For pattern-based matches (contains, regex), substitute the comparison:
if (/\/order\/[a-z0-9-]+\/confirm$/.test(window.location.pathname)) {
userContext.trackConversion('order-confirmation-reached');
}Server pattern — fire from the route handler that renders the matching path. The visitor reaches /thank-you only by hitting your server, so the route handler is the natural place:
// Laravel — fire from the controller for the matching route
class CheckoutController
{
public function thankYou(Request $request)
{
$context = $request->attributes->get('convertContext'); // your visitor-id middleware
$context->trackConversion('purchase-confirmation-reached');
return view('checkout.thank-you');
}
}// If you'd rather declare URL goals next to the route definitions, group them in middleware:
class FireUrlGoalsMiddleware
{
private array $urlGoals = [
'purchase-confirmation-reached' => '#^/thank-you$#',
'pricing-page-viewed' => '#^/pricing$#',
];
public function handle(Request $request, Closure $next)
{
$context = $request->attributes->get('convertContext');
$path = '/' . ltrim($request->path(), '/');
foreach ($this->urlGoals as $goalKey => $pattern) {
if (preg_match($pattern, $path)) {
$context->trackConversion($goalKey);
}
}
return $next($request);
}
}# Rails — fire from the controller for the matching route
class CheckoutController < ApplicationController
def thank_you
@convert_context.track_conversion("purchase-confirmation-reached")
render :thank_you
end
end# Declare URL goals in one place and fire them from a before_action.
class ApplicationController < ActionController::Base
URL_GOALS = {
"purchase-confirmation-reached" => %r{\A/thank-you\z},
"pricing-page-viewed" => %r{\A/pricing\z}
}.freeze
before_action :fire_url_goals
private
def fire_url_goals
URL_GOALS.each do |goal_key, pattern|
@convert_context.track_conversion(goal_key) if request.path.match?(pattern)
end
end
end# Flask — fire from the matching route
@app.post("/thank-you")
def thank_you():
g.convert_ctx.track_conversion("purchase-confirmation-reached")
return render_template("thank_you.html")# Declare URL goals in one place and fire them from a before_request hook.
import re
URL_GOALS = {
"purchase-confirmation-reached": re.compile(r"\A/thank-you\Z"),
"pricing-page-viewed": re.compile(r"\A/pricing\Z"),
}
@app.before_request
def fire_url_goals():
for goal_key, pattern in URL_GOALS.items():
if pattern.match(request.path):
g.convert_ctx.track_conversion(goal_key)For many URL goals on the browser side, the equivalent generalization:
const urlGoals = [
{ goalKey: 'purchase-confirmation-reached', match: (p) => p === '/thank-you' },
{ goalKey: 'pricing-page-viewed', match: (p) => p === '/pricing' },
];
function evaluateUrlGoals() {
const path = window.location.pathname;
for (const g of urlGoals) {
if (g.match(path)) userContext.trackConversion(g.goalKey);
}
}The SDK dedups goal conversions per visitor (one fire per visitor per goal unless forceMultipleTransactions: true), so calling on every navigation / request is safe — repeats are silently dropped.
Future mobile SDKs: model URL-visit goals as screen-visit goals — fire from your screen's
onAppear/onResume/ route-listener hook. SametrackConversion(goalKey)call.
Form-submission goal — visitor submits a form
Browser and server both. UI config: a CSS selector targeting a <form>. The classic tracking script auto-listens on the storefront.
Browser pattern — same idea as click, but on submit:
document.body.addEventListener('submit', (e) => {
if (e.target.matches('form.newsletter-signup')) {
userContext.trackConversion('newsletter-signup-submitted');
}
});If your form is submitted via AJAX (no actual submit event), call trackConversion directly from your submit handler instead.
Server pattern — fire from the route that processes the form POST. This is often the more reliable place because it only runs after the form actually validates and persists:
class NewsletterController
{
public function subscribe(SubscribeRequest $request)
{
// ... validate + persist the subscription ...
$context = $request->attributes->get('convertContext');
$context->trackConversion('newsletter-signup-submitted');
return redirect()->route('newsletter.thanks');
}
}// Or in Node/Express server-side
app.post('/newsletter/subscribe', async (req, res) => {
// ... validate + persist ...
req.convertContext.trackConversion('newsletter-signup-submitted');
res.redirect('/newsletter/thanks');
});# Rails — fire from the action that processes the form POST
class NewsletterController < ApplicationController
def subscribe
# ... validate + persist the subscription ...
@convert_context.track_conversion("newsletter-signup-submitted")
redirect_to newsletter_thanks_path
end
end# Flask — fire from the view that processes the form POST
@app.post("/newsletter/subscribe")
def subscribe():
# ... validate + persist the subscription ...
g.convert_ctx.track_conversion("newsletter-signup-submitted")
return redirect(url_for("newsletter_thanks"))Future mobile SDKs: fire from the submit-button handler after your validation completes, same
trackConversioncall.
Custom / programmatically-triggered goal
Browser and server both. Tracking script: the customer pushes the exposed triggerConversion method onto the queue — _conv_q.push(['triggerConversion', goalId]) (or the object form _conv_q.push({ what: 'triggerConversion', params: { goalId: '...' } })).
SDK equivalent: just call trackConversion directly. There is no queue, no indirection.
function onSomeBusinessEvent() {
userContext.trackConversion('business-event-goal-key');
}public function onSomeBusinessEvent(): void
{
$this->convertContext->trackConversion('business-event-goal-key');
}def on_some_business_event
@convert_context.track_conversion("business-event-goal-key")
enddef on_some_business_event():
convert_ctx.track_conversion("business-event-goal-key")This is the most natural fit between the two worlds — anywhere you'd have pushed to _conv_q, you call trackConversion. The shape of the call is identical across every SDK; only the surrounding event handler differs.
Revenue / transactional goal
Browser and server both. UI config: a goal flagged as transactional/revenue-bearing. Tracking script auto-fires when the corresponding trigger (typically a click or URL goal) fires, attaching revenue the customer pushed onto _conv_q via pushRevenue / sendRevenue (or revenue Convert auto-detected from the page).
SDK pattern: pass conversionData on the trackConversion call. The most reliable place for this is server-side (e.g. an order webhook) because client-side conversions can be blocked or lost on the navigation away from checkout.
userContext.trackConversion('purchase-completed', {
conversionData: [
{ key: 'amount', value: 99.99 },
{ key: 'productsCount', value: 3 },
{ key: 'transactionId', value: 'order-12345' }
]
});use ConvertSdk\DTO\ConversionAttributes;
use ConvertSdk\DTO\GoalData;
use ConvertSdk\Enums\GoalDataKey;
$context->trackConversion('purchase-completed', new ConversionAttributes(
conversionData: [
new GoalData(GoalDataKey::Amount, 99.99),
new GoalData(GoalDataKey::ProductsCount, 3),
new GoalData(GoalDataKey::TransactionId, 'order-12345'),
],
));context.track_conversion(
"purchase-completed",
goal_data: { amount: 99.99, products_count: 3, transaction_id: "order-12345" }
)
# Ruby takes the dedup-bypass as a top-level keyword (not a nested setting):
# accumulate revenue across distinct transaction_ids while the conversion count stays 1.
context.track_conversion(
"purchase-completed",
goal_data: { amount: 99.99, transaction_id: "order-12346" },
force_multiple_transactions: true
)context.track_conversion(
"purchase-completed",
revenue=99.99,
conversion_data={"products_count": 3, "transaction_id": "order-12345"},
)
# Accumulate revenue across distinct transaction_ids while the conversion count
# stays 1 — force_multiple is a top-level keyword, not a nested setting:
context.track_conversion(
"purchase-completed",
revenue=99.99,
conversion_data={"transaction_id": "order-12346"},
force_multiple=True,
)Conversion count (unique visitors who converted) is dedup'd by visitor per goal — it stays 1 no matter how many times you fire. By default, though, repeat conversions for the same visitor + goal are dropped entirely, including their transaction events — so a returning customer's separate orders won't add to revenue on their own. To accumulate revenue across a visitor's distinct transactionId values (while keeping the conversion count at 1), pass conversionSetting: { forceMultipleTransactions: true } on each transactional call: the conversion event is still deduped, but the transaction is sent and Convert accumulates revenue by transactionId. See Tracking Conversions.
Engagement goals (time on page, scroll depth)
Browser-only trigger. Engagement metrics live in the client — a server can't observe how long someone spent on a page or how far they scrolled. Future mobile SDKs would express the same idea via screen-time observers or scroll-listener APIs.
These have no built-in SDK equivalent — the tracking script auto-watches; no SDK does. Implement the threshold yourself in the browser and call trackConversion when the condition is met:
// Scrolled 75% of page goal
let fired = false;
window.addEventListener('scroll', () => {
if (fired) return;
const pct = (window.scrollY + window.innerHeight) / (document.documentElement.scrollHeight || document.body.scrollHeight);
if (pct >= 0.75) {
userContext.trackConversion('scrolled-75-percent');
fired = true;
}
});
// 30 seconds on page goal
setTimeout(() => userContext.trackConversion('engaged-30s'), 30_000);Keep these listeners idempotent and tear them down on route changes if your stack uses SPA navigation, to avoid duplicate timers across pages.
4. Things the Tracking Script Does That the SDK Does Not
A brief checklist of behaviors you'll re-implement (or accept as unsupported):
| Tracking-script behavior | SDK equivalent |
|---|---|
| Auto page-view events on every URL change | None — the SDK only fires exposure events on runExperience and conversion events on trackConversion. If you need a page-view-like signal, fire a dedicated custom goal manually. |
| Visitor cookie generation + 6-month persistence | You generate and persist the visitor ID. See Client-Side Experimentation and Server-Side Experimentation for cookie helpers. |
| Cross-domain visitor linking via signal redirects | You plumb the visitor ID across domains yourself (URL parameter on outbound links, or sync server-side). |
| Audience re-evaluation on SPA navigation | Call runExperience / runExperiences again with updated locationProperties after navigation. |
| Consent queue gating | You gate calls behind your consent signal. See Client-Side Experimentation > Consent. |
_conv_q API (addListener, triggerConversion, pushRevenue, …) | Call SDK methods directly — there is no queue. Event hooks are available via convertSDK.on(SystemEvents.X, ...); see Event System. |
| Visual Editor changes (DOM mutations defined in the UI) | Not delivered to Fullstack-SDK callers. Variation changes in Fullstack are fullStackFeature change types — feature variables you read in your code, not pre-baked DOM mutations. See Data Model > Variation Changes. |
| Cross-experience mutual exclusion / experiment groups | Configured in the UI; the SDK respects it when bucketing. No additional code needed. |
5. Putting Trigger Detection in One Place
If your project has many goals and audiences, scattering trigger logic across components gets unwieldy fast. A central registry pattern keeps things tidy.
Browser — a single registry that binds each goal to its trigger when the SDK is ready:
const goalRegistry = {
'signup-cta-click': () =>
delegate('click', '.signup-cta'),
'newsletter-signup-submitted': () =>
delegate('submit', 'form.newsletter-signup'),
'pricing-page-viewed': () =>
onPathMatch((p) => p === '/pricing'),
'purchase-completed': () =>
onCustomEvent('checkout:completed', (e) => ({
conversionData: [
{ key: 'amount', value: e.detail.total },
{ key: 'transactionId', value: e.detail.orderId }
]
})),
};
// Bind everything once, after the SDK is ready and the context is created.
for (const [goalKey, bind] of Object.entries(goalRegistry)) {
bind((attrs) => userContext.trackConversion(goalKey, attrs));
}The helpers (delegate, onPathMatch, onCustomEvent) are whatever utilities fit your stack.
Server — a single class that owns server-side triggers, invoked from middleware, controllers, webhooks, and queue workers as appropriate:
use ConvertSdk\Interfaces\ContextInterface;
use ConvertSdk\DTO\ConversionAttributes;
use ConvertSdk\DTO\GoalData;
use ConvertSdk\Enums\GoalDataKey;
class ConvertGoals
{
public function __construct(private ContextInterface $context) {}
public function signupCtaClick(): void
{
$this->context->trackConversion('signup-cta-click');
}
public function newsletterSignupSubmitted(): void
{
$this->context->trackConversion('newsletter-signup-submitted');
}
public function pricingPageViewed(): void
{
$this->context->trackConversion('pricing-page-viewed');
}
public function purchaseCompleted(float $total, string $orderId): void
{
$this->context->trackConversion('purchase-completed', new ConversionAttributes(
conversionData: [
new GoalData(GoalDataKey::Amount, $total),
new GoalData(GoalDataKey::TransactionId, $orderId),
],
));
}
}class ConvertGoals
def initialize(context)
@context = context
end
def signup_cta_click
@context.track_conversion("signup-cta-click")
end
def newsletter_signup_submitted
@context.track_conversion("newsletter-signup-submitted")
end
def pricing_page_viewed
@context.track_conversion("pricing-page-viewed")
end
def purchase_completed(total, order_id)
@context.track_conversion(
"purchase-completed",
goal_data: { amount: total, transaction_id: order_id }
)
end
endclass ConvertGoals:
def __init__(self, context):
self._context = context
def signup_cta_click(self):
self._context.track_conversion("signup-cta-click")
def newsletter_signup_submitted(self):
self._context.track_conversion("newsletter-signup-submitted")
def pricing_page_viewed(self):
self._context.track_conversion("pricing-page-viewed")
def purchase_completed(self, total, order_id):
self._context.track_conversion(
"purchase-completed",
revenue=total,
conversion_data={"transaction_id": order_id},
)The point is the same in both worlds: keep the mapping between UI-configured goal keys and runtime triggers in one place that mirrors the structure you can see in the Convert UI. This is roughly the responsibility the tracking script took on for you; doing it explicitly in your code makes the contract obvious.
6. When to Re-Run runExperience
runExperienceThe classic tracking script re-evaluates audiences on every URL change. No SDK does this automatically — runExperience runs when you call it.
Browser — three reasonable strategies:
- Once per session. Bucket every active experience at app boot, store the result, never re-run. Simplest, but audience rules that depend on URL won't catch SPA navigations into newly-eligible pages.
- On every navigation. Hook your router and call
runExperiencesagain with the updatedlocationProperties.url. Catches SPA-eligible audiences correctly. The SDK's in-memory state dedups these re-runs within a single page load, so SPA navigations don't re-fire the exposure event; only a full reload (a new SDK instance) starts fresh and re-fires it once. The server-side metrics processor also dedups by visitor+experience, so reports are unaffected either way — aDataStoreis what extends the dedup across reloads and sessions to keep the wire clean. - Configure a DataStore. With a persistent DataStore set on the SDK, the dedup is on the SDK side — re-running
runExperiencereads the cached decision and skips the wire call. This is the recommended approach if you re-run on every navigation. See Persistent DataStore.
Server — call runExperience(s) once per request that needs the decision, with the request's URL and visitor properties. The SDK's batching + per-request shutdown flush handle the event tally; a DataStore (database, cache, etc.) is what makes the dedup span across requests for the same visitor.
Pick the strategy that matches your traffic and tolerance for duplicate exposure events on the wire. None of them affect the correctness of bucketing decisions — those are deterministic per visitor on every SDK.