Skip to content

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.

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 in sessionStorage)
  • Timestamps every event
  • Prunes old events to stay within memory limits

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
}
// 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 5
client.track('custom_signal', { signalId: 'engagement', value: 5 })
// Generic event — counted in signals.totalEvents
client.track('page_view', { path: '/dashboard' })

Signals are recomputed automatically after every track() call (debounced at 100ms to avoid performance issues). Here is how events map to signals:

Event namePropertiesSignal 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 eventsignals.totalEvents += 1

Additionally, these signals are computed from the full event history:

SignalComputation
sessionCountCount of unique sessionId values
firstSeenAtEarliest event timestamp
lastSeenAtLatest event timestamp
daysSinceSignupDays between traits.signupDate and now

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>
}
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
},
}
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)
},
}

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.

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 pruned
  • maxAgeMs (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.

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' },
}