Tracking & Signals
Tracking is how Presage learns about user behavior. Every client.track() call feeds into the signal computation pipeline, which updates the UserContext and can trigger rule re-evaluation.
Built-in Tracker
Section titled “Built-in Tracker”Presage ships with a built-in event store that keeps events in memory. No external service is required.
const client = createAdaptiveClient({ tracker: { builtIn: { maxEvents: 1000, // Keep at most 1000 events (default) maxAgeMs: 30 * 24 * 60 * 60 * 1000, // Prune events older than 30 days (default) }, },})The built-in tracker automatically:
- Assigns a
sessionId(persisted insessionStorage) - Timestamps every event
- Prunes old events to stay within memory limits
Event Schema
Section titled “Event Schema”Every tracked event follows this structure:
interface TrackerEvent { name: string properties?: Record<string, unknown> timestamp: number // Auto-set to Date.now() userId?: string // Set after client.identify() sessionId: string // Auto-generated per browser session}Tracking Events
Section titled “Tracking Events”// Feature usage — increments signals.featureUsage['dashboard-export']client.track('feature_used', { featureId: 'dashboard-export' })
// Click tracking — increments signals.clickMap['sidebar-analytics']client.track('click', { elementId: 'sidebar-analytics' })
// Custom signals — increments signals.customSignals['engagement'] by 5client.track('custom_signal', { signalId: 'engagement', value: 5 })
// Generic event — counted in signals.totalEventsclient.track('page_view', { path: '/dashboard' })Signal Computation
Section titled “Signal Computation”Signals are recomputed automatically after every track() call (debounced at 100ms to avoid performance issues). Here is how events map to signals:
| Event name | Properties | Signal updated |
|---|---|---|
feature_used | { featureId: string } | signals.featureUsage[featureId] += 1 |
click | { elementId: string } | signals.clickMap[elementId] += 1 |
custom_signal | { signalId: string, value?: number } | signals.customSignals[signalId] += value |
| Any event | — | signals.totalEvents += 1 |
Additionally, these signals are computed from the full event history:
| Signal | Computation |
|---|---|
sessionCount | Count of unique sessionId values |
firstSeenAt | Earliest event timestamp |
lastSeenAt | Latest event timestamp |
daysSinceSignup | Days between traits.signupDate and now |
Pluggable Adapters
Section titled “Pluggable Adapters”You can forward events to external analytics services by implementing the TrackerAdapter interface:
interface TrackerAdapter { name: string track(event: TrackerEvent): void | Promise<void> identify(userId: string, traits: UserTraits): void | Promise<void> flush?(): Promise<void>}Example: PostHog Adapter
Section titled “Example: PostHog Adapter”import posthog from 'posthog-js'import type { TrackerAdapter, TrackerEvent, UserTraits } from '@presage-kit/core'
const posthogAdapter: TrackerAdapter = { name: 'posthog',
track(event: TrackerEvent) { posthog.capture(event.name, event.properties) },
identify(userId: string, traits: UserTraits) { posthog.identify(userId, traits) },
async flush() { // PostHog batches automatically, but you can force a flush here },}Example: Segment Adapter
Section titled “Example: Segment Adapter”import type { TrackerAdapter, TrackerEvent, UserTraits } from '@presage-kit/core'
const segmentAdapter: TrackerAdapter = { name: 'segment',
track(event: TrackerEvent) { window.analytics.track(event.name, { ...event.properties, sessionId: event.sessionId, }) },
identify(userId: string, traits: UserTraits) { window.analytics.identify(userId, traits) },}Registering Adapters
Section titled “Registering Adapters”Pass adapters in the client config:
const client = createAdaptiveClient({ tracker: { adapters: [posthogAdapter, segmentAdapter], }, // ...rules, persistence})All adapters receive every track() and identify() call alongside the built-in store.
Debouncing and Performance
Section titled “Debouncing and Performance”Signal recomputation is debounced at 100ms. If you fire multiple track() calls in rapid succession (e.g., during a click burst), signals are only recomputed once after the burst settles.
The built-in event store enforces two limits:
maxEvents(default: 1000) — When exceeded, the oldest events are prunedmaxAgeMs(default: 30 days) — Events older than this are discarded
These defaults work well for most SaaS applications. Adjust them if your use case generates very high event volumes.
Using Signals in Rules
Section titled “Using Signals in Rules”Once signals are computed, use them in rule conditions just like traits:
{ id: 'frequent-user-shortcut', adaptationId: 'toolbar', priority: 10, conditions: { all: [ { field: 'signals.sessionCount', operator: 'gte', value: 20 }, { field: 'signals.featureUsage.export', operator: 'gte', value: 5 }, ], }, action: { type: 'show', variantId: 'with-keyboard-shortcuts' },}