Home / Guides / Analytics
Product guide

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.

In short
  • Block engagement is tracked with trackImpression, trackTap, trackCtaClick, trackDismiss and trackConversion; anything else is a custom or category:action event.
  • Nothing is recorded without analytics consent, or before the device has identified.
  • The SDK API answers 202 even when it drops events, so read dropReasons (JavaScript reports them through its onEventsDropped callback; other SDKs: see Testing: Reading dropReasons).
  • Every hour, each person's last 90 days become enrichment.analytics_* fields you can segment on.
Why teams use it

Business use cases

Content

Which offer works

Impressions, click rate and conversion rate per block instance and per A/B variant.

Funnels

Where people drop off

Onboarding, application and checkout funnels from your own events.

Support

What happened to this customer

The last 500 events for one person on their profile page.

Targeting

Engaged but not converted

The hourly roll-up turns clicks and recency into segment fields.

Instrumenting

What to track

EventSDK callUse
Block shown / tapped / CTA / dismissed / convertedtrackImpression, trackTap, trackCtaClick, trackDismiss, trackConversionContent performance (see Blocks)
Campaign shown / tappedtrackCampaignImpression, trackCampaignTapCampaign performance
Sign-up, sign-intrackSignUp, trackSignIn (identify first). Kotlin has no trackSignUp/trackSignIn: it uses client.identity(IdentityEvent.signUp(method)) or trackIdentity(CFIdentityEvent.SIGN_UP, username = …).Journeys, attribution
Anything elseclient.track('loan_application_started', {}, { amount: 50000 }) (event, block or {}, properties)Funnels, journeys, segments
Categoriesclient.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);
Instrumenting

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' }):

HelperExample events
trackBankingTransfer initiated, completed or failed; bill payment; card activated, blocked or limit changed; statement downloaded
trackInsuranceQuote, policy renewal, claim started, document uploaded, claim submitted, claim status viewed
trackHealthcareAppointment booked, cancelled, rescheduled or checked in; lab results viewed; prescription refill; video consult
trackTravelSearch, booking, check-in, boarding pass, miles redeemed, upgrade
trackRetailProduct viewed, search, wishlist, cart add/remove/abandon, checkout, purchase
trackFoodDeliveryOrder placed through delivered, or cancelled

Fintech apps use trackBanking and custom events; there's no separate fintech helper yet.

How it works

Event pipeline

From track() to a dashboard and a segment
  1. 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.

  2. 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.

  3. Rejects malformed batches outright; checks the device's consent; stamps the linked user, the environment, and the device's IP-derived country and city.
  4. If the event pipeline is unavailable the request fails with 503 and nothing is queued, so the SDK can retry safely.
  5. JavaScript: the onEventsDropped callback. Swift, Kotlin and Flutter: see Testing: Reading dropReasons.
  6. Stored near real time and shown on the dashboards.
  7. The last 90 days of events, per person, are totalled and saved as profile attributes you can segment on.
Responses

Accepted, dropped, rejected

ResponseMeaningWhat the SDK does
202 accepted: nQueued for analyticsDone
202 device_not_identifiedThe device never identified, or its identify expired (after 24 h)Reported as dropped; identify on launch and resume
202 consent_not_grantedNo analytics consent for the deviceReported as dropped
202 invalid_string_signalA sensor signal failed validationReported as dropped
202 sensor_not_enabledA sensor signal named a key that isn't an enabled sensor in the portalReported as dropped
400 MAX_BATCH_SIZE, INVALID_EVENT_TYPE, INVALID_TIMESTAMP, ENDPOINT_MISMATCHThe whole batch is refused; nothing is storedDropped (a client bug to fix)
403 user mismatchA user id in the body isn't the device's linked userDropped
429 SDK_RATE_LIMIT_EXCEEDEDWorkspace over 1,000 requests/minKept and retried
5xx / timeoutOutcome unknownReported as uncertain (JavaScript: onEventsUncertain; Swift flushEvents() receipt, Kotlin TrackingDisposition, Flutter debug log); retried only with eventRetryPolicy: 'at-least-once'
Results

What the dashboards show

  • Impressions count impression, banner_impression and block_view; clicks count tap, banner_click and cta_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.
Targeting

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.

Environments

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

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.
Delivery

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.

Results

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.

Troubleshooting

When it doesn't work

What you seeWhy, and the fix
202 with consent_not_granted in dropReasonsThe 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_identifiedThe device never identified, the identify failed, or its identify expired (after 24 h). Identify on app launch and on resume.
400 ENDPOINT_MISMATCHSign-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_TIMESTAMPThe 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_EXCEEDEDThe 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 dashboardsThey were sent with a test key, so they're stamped env: test. Switch the dashboard to test mode to see them.

Next

  • Sensors: measure any text in your app without inventing event names.
  • Segments: target people using the enrichment.analytics_* fields the roll-up writes.