Data model

Events

The atomic unit of every Sankofa product. Naming conventions, properties, default enrichment, allow/deny lists, and how events flow into ClickHouse.

An event is a named action with optional structured properties. Sankofa builds funnels, cohorts, retention charts, replays, surveys, and experiments by querying events — so the way you model them determines what you can analyse later. Once you ship checkout_started in v1, you can never rename it without breaking every downstream view.

This page covers naming conventions, property modelling, default enrichment, what gets indexed, and what happens to an event after track returns.

Anatomy of an event

A single event row in ClickHouse looks like this (simplified):

JSON
{
"event_name": "checkout_started",
"distinct_id": "user_123",
"anon_id": "anon_6b87f2",
"session_id": "sess_abc123",
"timestamp": "2026-05-09T14:32:01.482Z",
"ingested_at": "2026-05-09T14:32:01.612Z",
"environment": "live",
"properties": {
  "cart_value": 49.99,
  "item_count": 3,
  "currency": "USD"
},
"default_properties": {
  "$os": "ios",
  "$os_version": "17.4",
  "$device_model": "iPhone 15 Pro",
  "$app_version": "2.4.1",
  "$country": "GH",
  "$city": "Accra",
  "$timezone": "Africa/Accra"
},
"lib": "@sankofa/react-native",
"lib_version": "1.0.4"
}

event_name, distinct_id, and timestamp are required; everything else is filled in by the SDK or the engine.

Naming conventions

Good event names are stable, descriptive, and predictable. Three rules go a long way:

  1. Use snake_case, present tense, verb-noun

    signup_completed ✓   checkoutStarted ✗   Checkout started

    Past tense is the convention because every event represents a thing that just happened. Verb-noun is grep-friendly — when you're hunting for "everything checkout-related," ^checkout_ matches.

  2. Be specific enough to be useful, vague enough to last

    payment_failed ✓   payment_failed_visa ✗ (use a card_brand property)   event ✗ (too vague)

    The card brand is a property dimension that varies; the event itself is "a payment failed."

  3. One event per discrete action

    signup_completed ✓   signup_started_then_completed

    Two distinct moments in a user flow are two events. They get joined into a funnel server-side; you don't need to compress them into one name.

A few well-chosen events from real Sankofa customers:

  • signup_completed — the moment account creation succeeds.
  • checkout_started, checkout_completed, checkout_abandoned — the funnel triplet.
  • payment_failed — with failure_code, card_brand, amount properties.
  • invite_accepted — workspace + role attached as properties.
  • api_request_failed — server-side; endpoint, status_code, latency_ms properties.
  • feature_used — when something fine-grained doesn't yet warrant its own name.

Properties: what to put on the event

Use properties for the dimensions that vary. Examples:

  • plan"free", "pro", "growth", "enterprise"
  • currency"USD", "GHS", "EUR"
  • screen — the screen the action happened on
  • source — referrer, campaign, traffic origin
  • failure_code — for error events
  • experiment_variant — auto-attached if you use Switch's exposure tracking; manually set otherwise

Properties accept strings, numbers, booleans, JSON arrays, and JSON objects. Dates are stringified to ISO 8601. BigInt is stringified.

Default properties

Every SDK adds default properties to every event for free — geo, OS, device, app metadata. The engine promotes the highest-value defaults into indexed columns so you can break down or filter by them at query time without paying a JSON-decode cost.

$countrystring
Resolved from GeoIP at ingest, or explicitly from SDK context where available.
$regionstring
Subdivision (state, province) from GeoIP.
$citystring
City from GeoIP.
$timezonestring
Resolved from GeoIP, or from the device locale on mobile SDKs.
$osstring
Operating system family — `web`, `ios`, `android`, `linux`, `darwin`, `windows`.
$os_versionstring
OS version string.
$device_modelstring
Hardware identifier (mobile only).
$browserstring
Browser family (web only).
$app_versionstring
Your app's user-facing version string.
$session_idstring
Stable session identifier; correlates with replays.

See Default properties for the complete list and per-platform behaviour.

Allow and deny lists

Configure per-project allow / deny lists at Settings → Data → Properties:

  • Allow list — only properties named here are persisted. Everything else is dropped at ingest. Use this when you have a strict PII policy.
  • Deny list — properties named here are dropped, everything else is persisted. Use this for quick redaction of fields you've identified as PII (email, phone_number, ssn).

Allow / deny apply to property names only — not values. To redact specific values (e.g. mask all-but-last-four-digits), do it at the SDK call site.

Event ingestion path

After your SDK calls track:

  1. The SDK queues

    Events go onto the SDK's offline-first queue. On web that's IndexedDB; on mobile that's SQLite (via GRDB on iOS, the Android SDK's queue table on Android, sqflite on Flutter). On server SDKs the queue is in-memory.

  2. Flush on schedule

    The queue flushes on flushIntervalSeconds cadence (default 30s on mobile, 5s on web), on app suspend / background, on flush() call, or on shutdown.

  3. Engine ingests

    The flush hits POST /api/v1/batch with up to batchSize events. The engine validates the API key, fills in environment, applies allow / deny rules, runs the GeoIP lookup, and writes a row to ClickHouse.

  4. Available in queries

    Events are visible in Live events within ~1 second of ingest. Funnels, cohorts, and retention queries pick them up on the next refresh window (typically 30 s on Pro, near-realtime on Enterprise).

See Ingestion model for the full path including backpressure and retry semantics.

What's next

Edit this page on GitHub