Skip to content

Events & Analytics

RevTurbine reports what it did — a placement was shown, a CTA was clicked — and, when you opt in, what the user was doing around it. Placement lifecycle is tracked automatically; everything else is opt-in, additive, and privacy-safe by default. An integration that sets no telemetry options behaves exactly as before.

Every event carries a sortable, unique event_id, is PII-redacted before it leaves the browser, and is batched and delivered best-effort (a failed send never throws into your app, and a retry resends the identical row so it can’t duplicate).

Placement components and headless controllers instrument the placement lifecycle for you — you don’t wire these up:

EventWhen
impressionA placement is presented (see viewport exposure)
placement_interactionThe canonical interaction event, discriminated by interaction_type: dismiss · remind_me_later · cta_clicked · cta_completed · suppress
placement_rendered / placement_exposedThe visual root rendered / entered the viewport (when you attach exposureRef)

placement_interaction is the one canonical placement event — earlier standalone placement_dismissed / _snoozed / _converted events are retired.

In React, useTrack returns a track(name, data?, options?) bound to the surrounding scope. Reserved names (canonical identity + provenance fields) are dropped so you can’t overwrite a system value; without a provider, track is a safe no-op.

import { useTrack } from '@revturbine/sdk';
function AdvancedFilters() {
const track = useTrack();
return (
<button onClick={() => track('feature_used', { feature: 'advanced_filters' })}>
Filter
</button>
);
}

Headless / server code uses sdk.track:

await sdk.track('checkout_completed', { plan: 'professional', revenue: 99 });
OptionMeaning
area / actionScope labels; merge outer → inner → invocation
purposeAdvisory intent (e.g. 'engagement'). A server-side allowlist — not this value — decides whether an event feeds scoring
onceEmit at most once for the hook’s lifetime
dedupeKeyEmit at most once per key
immediateSend now instead of batching

TelemetryScope sets ambient area / action / purpose for its descendants. It renders its children unchanged — no wrapper element, no added role — and nested scopes merge inner-over-outer, with per-event options winning.

import { TelemetryScope, useTrack } from '@revturbine/sdk';
<TelemetryScope area="billing" purpose="engagement">
<UpgradePanel /> {/* every useTrack() here inherits area="billing" */}
</TelemetryScope>
Component / hookFires
TrackOnViewIts event once when the element scrolls into view (falls back to render when IntersectionObserver is unavailable). Strict-Mode safe
EngagementAreaA one-shot engagement_view, accrues engagement_dwell (dwell_ms) only while onscreen and the tab is visible, and bubbles descendant clicks as engagement_interaction
Track (asChild)Composes onto a child’s onClick (no wrapper) — telemetry fires only if the child didn’t preventDefault; leaves the child’s accessible name and disabled state unchanged
useTelemetryPropsTelemetry props to spread onto a primitive that can’t take a wrapper
import { TrackOnView, Track } from '@revturbine/sdk';
export const Examples = () => (
<>
<TrackOnView event="hero_seen" data={{ variant: 'a' }}>
<Hero />
</TrackOnView>
<Track event="cta_clicked" data={{ plan: 'pro' }} asChild>
<button onClick={handleUpgrade}>Upgrade</button>
</Track>
</>
);

useTrackedAction wraps an async action, emitting ${name}_started then ${name}_completed or ${name}_failed (with a non-sensitive error_category — never the raw message). It preserves the return value and re-throws, so it’s a drop-in wrapper.

import { useTrackedAction } from '@revturbine/sdk';
const { run, isRunning } = useTrackedAction('export_pdf', () => exportPdf());
<button disabled={isRunning} onClick={() => run()}>Export</button>

useGatedAction is the React analog of rt.gate(action, fn): it delegates to the SDK so it emits the same gate_attemptedgate_allowed / gate_denied sequence (not a fork), running the action wrapped in tracked-action telemetry only when allowed. A passively-rendered <Gate> instead emits gate_evaluated.

telemetry.consent gates event creation ahead of every destination:

ValueEffect
granted (default)Emit normally
deniedNo event is created — nothing reaches ingest, consumers, or integrations
pendingDropped in MVP; never persisted

Change it at runtime with no remount:

sdk.setTelemetryConsent('denied'); // stop; 'granted' resumes on the next event

The keyless anonymous SDK-init beacon (anonymousTelemetry) is controlled separately and is unaffected by consent.

Opt into hand-authored capture with domCapture on the provider. One delegated listener per event reads only allowlisted data-rt-* attributes — never element text, input values, hrefs, or selectors — and always skips password / file / hidden / payment-autocomplete controls. A data-rt-no-capture ancestor opts an element and its subtree out. Collected values pass through the same PII redactor.

<RevTurbineProvider options={options} domCapture>
<button data-rt-event="cta_clicked" data-rt-prop-plan="pro">Upgrade</button>
</RevTurbineProvider>

placementExposure controls when the presentation-writing impression fires:

ModeImpression fires
legacy_resolution (default)At decision resolution — exactly as today
renderWhen the placement renders
viewportWhen it scrolls into the viewport; falls back to resolution (exposure_basis: 'render_fallback') when IntersectionObserver is unavailable

usePlacement returns exposureRef; slot components receive the same ref through the additive exposureRef prop on PlacementSlotProps — the renderer threads it to every slot automatically, so a custom component just attaches it to its true visual root. (The built-in components attach it themselves in an upcoming release; until then, viewport exposure with built-ins uses the render fallback.)

Forward the same semantic events to a third-party tool without adding a second transport. createPostHogIntegration adds an opt-in identity lifecycle (all sync flags default false, so it behaves like the capture-only provider until you turn one on); PostHog is injected, never bundled.

import posthog from 'posthog-js';
import { createPostHogIntegration } from '@revturbine/sdk';
initRevTurbine({
domainProviders: [createPostHogIntegration({ posthog, syncIdentity: true })],
// ...
});

A throwing integration never blocks RevTurbine ingest, and an ingest failure never blocks a configured mirror.

Trigger events are semantic lifecycle signals the SDK uses to re-evaluate targeting. Emit one and all active placements re-evaluate:

await sdk.emitTrigger('trial_expiring', { days_remaining: 2 });
await sdk.emitTrigger('usage_limit_approaching', {
entitlement_handle: 'api_calls', usage_percent: 95,
});

Built-in triggers: trial_started · trial_expiring · trial_expired · usage_limit_approaching · usage_limit_exceeded · feature_gated · payment_retry_required · subscription_renewing.

Set test: true at init when an SDK instance generates test traffic — a staging deploy, an integration test, a QA walkthrough:

const rt = initRevTurbine({
tenantId: 'your-tenant',
ingestPublicKey: 'pub_…',
test: true, // this instance's events are test traffic
});

Every emitted event (clickstream and treatment interactions) is then stamped test: true, and RevTurbine analytics exclude those events by default from every metric, rollup, and denominator. An include_test=1 toggle on the analytics pipes reveals them when you want to inspect test traffic.

This is a deliberate, code-passed decision: pass it explicitly from your own configuration. The SDK never infers it from the environment — no NODE_ENV sniffing. Whether traffic is test traffic is your call, not a deployment artifact. Omitted or false, events are emitted unchanged (byte-identical to earlier SDK versions).

When a placement or entitlement can’t resolve for infrastructure reasons, the SDK reports it instead of failing silently — a resolution_failure diagnostic on the anonymous meta channel:

ReasonEmitted when
placement_not_registeredA decision is requested for a placement the app never registered — the classic “my payload renders nothing, silently”
config_unavailableThe Playbook never arrived, so a placement fell back or an entitlement check denied without a rule saying no
sdk_disabled_provider_failureThe provider chain failed and the SDK disabled itself fail-closed

Diagnostics carry only author-defined handles (placement, slot, plan, entitlement) and closed reason codes — never user context, free text, or your tenant identifier (deployments are counted through a one-way hash). They’re deduplicated per session and capped, fire from keyed production installs as well as keyless ones, and are silenced by either opt-out:

initRevTurbine({ analytics: false }); // silences diagnostics too
initRevTurbine({ anonymousTelemetry: false }); // likewise

Rule-based denials and local mode’s default behavior are not failures and never emit.

Events that carry the area / action / purpose taxonomy (from TelemetryScope, useTrack options, EngagementArea, and tracked actions) are additionally stored with the taxonomy as first-class analytics columns — so engagement queries group by area and action directly, with no JSON unpacking and no configuration on your side. Untagged events are unaffected.

  • Redaction. Email- and card-shaped values in properties and traits are scrubbed to [REDACTED] before any destination — one sanitized envelope, every mirror. Best-effort, not a guarantee: don’t put PII in event data.
  • Advisory purpose, server-owned scoring. A client-declared purpose never grants engagement eligibility; a static server-side allowlist decides which event names feed scoring, regardless of purpose.
  • Delivery. Events batch and flush on size, interval, or page-unload. A transient failure is retried with the byte-identical row, so storage collapses it to one; a permanent failure is dropped silently. In local_only mode nothing is sent at all — the right mode for demos and examples.