Analytics
Every impression, tap, conversion and custom event your app sends lands in ContentFlow's analytics store within seconds. It powers the dashboards and per-person activity, measures every block, campaign and A/B variant, and is rolled up into profiles every hour so behaviour becomes something you can target.
- Block engagement is tracked with
trackImpression,trackTap,trackCtaClick,trackDismissandtrackConversion; anything else is a custom orcategory:actionevent. - Nothing is recorded without analytics consent, or before the device has identified.
- The SDK API answers
202even when it drops events, so readdropReasons(JavaScript reports them through itsonEventsDroppedcallback; other SDKs: see Testing: Reading dropReasons). - Every hour, each person's last 90 days become
enrichment.analytics_*fields you can segment on.
Business use cases
Which offer works
Impressions, click rate and conversion rate per block instance and per A/B variant.
Where people drop off
Onboarding, application and checkout funnels from your own events.
What happened to this customer
The last 500 events for one person on their profile page.
Engaged but not converted
The hourly roll-up turns clicks and recency into segment fields.
What to track
| Event | SDK call | Use |
|---|---|---|
| Block shown / tapped / CTA / dismissed / converted | trackImpression, trackTap, trackCtaClick, trackDismiss, trackConversion | Content performance (see Blocks) |
| Campaign shown / tapped | trackCampaignImpression, trackCampaignTap | Campaign performance |
| Sign-up, sign-in | trackSignUp, trackSignIn (identify first). Kotlin has no trackSignUp/trackSignIn: it uses client.identity(IdentityEvent.signUp(method)) or trackIdentity(CFIdentityEvent.SIGN_UP, username = …). | Journeys, attribution |
| Anything else | client.track('loan_application_started', {}, { amount: 50000 }) (event, block or {}, properties) | Funnels, journeys, segments |
| Categories | client.track('commerce:purchase', {}, { amount: 99, currency: 'SAR' }), or the facade's ContentFlow.commerce(), .engagement(), .identity(), .growth(), .account(), .system(), .location() | Sent as category:action, split by ContentFlow into a category and an action |
Unknown event names are accepted and stored as custom events. Names can't be empty or contain control characters.
// 1. A custom event in a category, sent as commerce:purchase.
client.track('commerce:purchase', {}, { amount: 99, currency: 'SAR' });
// A name without a category works too:
// client.track('loan_application_started', {}, { amount: 50000 });
// 2. An engagement event: the person tapped the block's call to action.
client.trackCtaClick(block);import { useCF, useCFBlock } from '@contentflow/sdk/react';
function OfferCard() {
const cf = useCF();
const block = useCFBlock('home_banner');
// 1. A custom event in a category, sent as commerce:purchase (call it from your checkout).
const onPurchase = () => cf.track('commerce:purchase', {}, { amount: 99, currency: 'SAR' });
// A name without a category works too:
// cf.track('loan_application_started', {}, { amount: 50000 });
if (!block) return null;
// 2. An engagement event: the person tapped the block's call to action.
return <button onClick={() => cf.trackCtaClick(block)}>{block.ctaLabel}</button>;
}import { Button } from 'react-native';
import { useCF, useCFBlock } from '@contentflow/sdk/react-native';
function OfferCard() {
const cf = useCF();
const block = useCFBlock('home_banner');
// 1. A custom event in a category, sent as commerce:purchase (call it from your checkout).
const onPurchase = () => cf.track('commerce:purchase', {}, { amount: 99, currency: 'SAR' });
// A name without a category works too:
// cf.track('loan_application_started', {}, { amount: 50000 });
if (!block) return null;
// 2. An engagement event: the person tapped the block's call to action.
return <Button title={block.ctaLabel} onPress={() => cf.trackCtaClick(block)} />;
}// 1. A custom event in a category, sent as commerce:purchase.
try await client.commerce(CommerceEvent(action: .purchase, amount: 99, currency: "SAR"))
// 2. An engagement event: the person tapped the block's call to action.
try await client.trackEngagement(.ctaClick(block))import click.contentflow.events.CommerceAction
import click.contentflow.events.CommerceEvent
// 1. A custom event in a category, sent as commerce:purchase.
client.commerce(CommerceEvent(commerceAction = CommerceAction.Purchase, amount = 99.0, currency = "SAR"))
// 2. An engagement event: the person tapped the block's call to action.
client.track(CFEngagementEvent.of(block, CFEngagementEvent.CTA_CLICK))final cf = ContentFlow.instance;
// 1. A custom event in a category, sent as commerce:purchase.
await cf.commerce(CommerceEvent.typed(action: CommerceAction.purchase, amount: 99, currency: 'SAR'));
// 2. An engagement event: the person tapped the block's call to action.
await cf.trackCtaClick(blockKey: block.key, instanceId: block.id);Industry vocabularies
Typed helpers record standard events per sector, so dashboards and journeys speak your business's language, for example client.trackBanking('transfer_completed', { amount: 2500, currency: 'SAR' }):
| Helper | Example events |
|---|---|
trackBanking | Transfer initiated, completed or failed; bill payment; card activated, blocked or limit changed; statement downloaded |
trackInsurance | Quote, policy renewal, claim started, document uploaded, claim submitted, claim status viewed |
trackHealthcare | Appointment booked, cancelled, rescheduled or checked in; lab results viewed; prescription refill; video consult |
trackTravel | Search, booking, check-in, boarding pass, miles redeemed, upgrade |
trackRetail | Product viewed, search, wishlist, cart add/remove/abandon, checkout, purchase |
trackFoodDelivery | Order placed through delivered, or cancelled |
Fintech apps use trackBanking and custom events; there's no separate fintech helper yet.
Event pipeline
- Queued in memory: impressions deduplicated per instance per session; up to 100 queued, oldest dropped first.
If this step is skipped: Before analytics consent, track calls are dropped in the SDK and reported as consent_not_granted. They're not held for a later grant.
- Sent when 10 are queued, every 15 s, on going online, and with a keepalive request when the app goes to the background. Campaign taps are sent immediately.
If this step is skipped: Before the device's first identify ContentFlow acknowledges with 202, accepted: 0, device_not_identified. The SDK holds events up to 10 s for identify.
- Rejects malformed batches outright; checks the device's consent; stamps the linked user, the environment, and the device's IP-derived country and city.
- If the event pipeline is unavailable the request fails with 503 and nothing is queued, so the SDK can retry safely.
- JavaScript: the
onEventsDroppedcallback. Swift, Kotlin and Flutter: see Testing: Reading dropReasons. - Stored near real time and shown on the dashboards.
- The last 90 days of events, per person, are totalled and saved as profile attributes you can segment on.
Accepted, dropped, rejected
| Response | Meaning | What the SDK does |
|---|---|---|
202 accepted: n | Queued for analytics | Done |
202 device_not_identified | The device never identified, or its identify expired (after 24 h) | Reported as dropped; identify on launch and resume |
202 consent_not_granted | No analytics consent for the device | Reported as dropped |
202 invalid_string_signal | A sensor signal failed validation | Reported as dropped |
202 sensor_not_enabled | A sensor signal named a key that isn't an enabled sensor in the portal | Reported as dropped |
400 MAX_BATCH_SIZE, INVALID_EVENT_TYPE, INVALID_TIMESTAMP, ENDPOINT_MISMATCH | The whole batch is refused; nothing is stored | Dropped (a client bug to fix) |
| 403 user mismatch | A user id in the body isn't the device's linked user | Dropped |
429 SDK_RATE_LIMIT_EXCEEDED | Workspace over 1,000 requests/min | Kept and retried |
| 5xx / timeout | Outcome unknown | Reported as uncertain (JavaScript: onEventsUncertain; Swift flushEvents() receipt, Kotlin TrackingDisposition, Flutter debug log); retried only with eventRetryPolicy: 'at-least-once' |
What the dashboards show
- Impressions count
impression,banner_impressionandblock_view; clicks counttap,banner_clickandcta_click; click rate and conversion rate are both per impression. - Engagement over time, hourly activity, daily reach, conversions by segment, location and geofence activity, experiment results, and exports.
- Per person: the last 500 events, with an analytics card on the person's page.
- Funnels: from your events and from sensor trackers.
Behaviour becomes segments
Every hour (at minute 50) ContentFlow writes each person's last 90 days into enrichment attributes you can use in segments: enrichment.analytics_impressions, analytics_clicks, analytics_conversions, analytics_events_total, analytics_last_active_days and analytics_ctr. Example: clicked at least 3 times, no conversions, active in the last 7 days.
Test data
Events from a test key are stamped env: test, excluded from live dashboards and never update block counters. Test mode in the dashboard shows them, with a test summary and a button to delete test data.
Privacy and retention
- No analytics consent, no events: dropped in the SDK and again by the server.
- Events are kept for 365 days; hourly activity views for 30 days.
- Erasure deletes a device's events.
- Anonymous visitors are counted with a salted visitor hash that rotates monthly.
Offline and queued events
Events wait on the device while there is no network, within limits that differ by platform: JavaScript and Flutter keep a stored outbox that survives a restart, Swift keeps its queue in memory only, and Kotlin sends each event at once without queueing it. Every queue holds at most 500 items and drops the oldest first, and nothing is queued before analytics consent. Flush before sign-out: Flutter's logout() clears its queues. The limits, retries, inspection methods and flush samples per platform are in Offline, queues and delivery.
Getting data out
- Reports: in the dashboard, Reports requests a report across segments, people, audit logs, analytics or delivery, with filters and a choice of columns. Finished files are under Downloads, and Scheduled repeats a report on a schedule.
- Per person, from your backend: the read key answers which devices and segments ContentFlow holds for a user (REST guide: read key).
- As things change: webhooks notify your systems of campaign changes (REST guide: webhooks).
Report types, filters, columns, formats and scheduling are covered in Reports and exports.
When it doesn't work
| What you see | Why, and the fix |
|---|---|
202 with consent_not_granted in dropReasons | The device has no analytics consent. Events tracked before consent are dropped in the SDK, not held for later. Record consent on identify (or setConsent) before you rely on events. |
202 with device_not_identified | The device never identified, the identify failed, or its identify expired (after 24 h). Identify on app launch and on resume. |
400 ENDPOINT_MISMATCH | Sign-up or sign-in was sent to /events. Use trackSignUp / trackSignIn, which send to /track-event. Nothing in the batch was stored. |
400 MAX_BATCH_SIZE, INVALID_EVENT_TYPE, INVALID_TIMESTAMP | The whole batch is refused and nothing is stored. Send at most 100 events per batch, with non-empty names that contain no control characters. |
429 SDK_RATE_LIMIT_EXCEEDED | The workspace is over 1,000 requests a minute. The SDK keeps the events and retries; a direct caller should wait for Retry-After. |
| Events don't appear in live dashboards | They were sent with a test key, so they're stamped env: test. Switch the dashboard to test mode to see them. |