@klyfa/react-native
v0.11.0
Published
Klyfa analytics, feature flags, kill-switch, and push registration for React Native.
Readme
@klyfa/react-native
Analytics, feature flags, kill-switch, and push registration for React Native apps on the Klyfa platform. One SDK, one init, one identity.
Install
npm install @klyfa/react-native react-native-device-info
# a store — AsyncStorage is the common choice, but see "Storage" below:
npm install @react-native-async-storage/async-storage
# optional, recommended — offline-aware event flushing:
npm install @react-native-community/netinfoSetup
// lib/klyfa.ts
import {Klyfa} from '@klyfa/react-native';
import {asyncStorageAdapter} from '@klyfa/react-native/async-storage';
import NetInfo from '@react-native-community/netinfo';
export async function initKlyfa() {
await Klyfa.init({
apiUrl: 'https://api.klyfa.com',
clientId: 'YOUR_CLIENT_ID',
storage: asyncStorageAdapter(),
networkInfo: NetInfo, // optional
});
}Call initKlyfa() once at app boot, before rendering.
Storage
storage is required, and says where the SDK keeps the install id, the
offline queue, the push token, the flag cache, and the attribution result.
It used to be an implicit AsyncStorage import, which meant every app got that
native module whether it wanted it or not — including apps that standardise on
something else, and apps that have to justify each native dependency in a
regulatory submission. Naming the store makes it your choice, makes the peer
dependency genuinely optional, and makes 'none' sayable.
The interface is three methods, and they may be synchronous:
interface KlyfaStorage {
get(key: string): string | null | Promise<string | null>;
set(key: string, value: string): void | Promise<void>;
remove(key: string): void | Promise<void>;
}So MMKV — which is synchronous, returns undefined for a miss (also
accepted), and can take an encryption key — is a shim:
import {MMKV} from 'react-native-mmkv';
const mmkv = new MMKV({id: 'klyfa', encryptionKey: await getKeyFromKeychain()});
storage: {
get: (k) => mmkv.getString(k) ?? null,
set: (k, v) => mmkv.set(k, v),
remove: (k) => mmkv.delete(k),
}The SDK never encrypts anything itself — if you need encryption at rest, it
belongs in the adapter, and the key is yours to manage. Note that MMKV is
plaintext unless you pass encryptionKey; a bare new MMKV() is no more
encrypted than AsyncStorage, so switching stores is not by itself a
confidentiality improvement. Be deliberate about
that: if the key rotates or fails to load, every value below is unreadable at
once, which looks exactly like a device that skipped its migration.
A store that throws is fine. Every call is best-effort — a full disk or a locked keystore degrades the SDK, it never fails your app.
storage: 'none'
Persists nothing:
await Klyfa.init({apiUrl, clientId, storage: 'none'});No install id is minted, so identity falls back to the server-derived (IP+UA)
device id; the offline queue lives in memory and dies with the process; and
Klyfa.clear() cannot detach a push token it never remembered, so pass one
explicitly. Flags and the kill-switch still work — they just re-fetch on every
cold start instead of serving a cache.
This is the strictest posture available and mirrors the default of
@klyfa/web. It is a real trade, not a free switch: without an install id the
anonymous→identified stitch has only the rotating IP+UA hash to work with, so
pre-login journeys get materially less reliable.
Changing stores
Switching stores strands whatever the old one held. Two of those keys fail silently — a lost install id quietly becomes a new anonymous identity, and a lost push token leaves a shared device bound to the previous user — so the SDK will carry them across for you:
import {asyncStorageAdapter} from '@klyfa/react-native/async-storage';
await Klyfa.init({
apiUrl, clientId,
storage: mmkvAdapter,
// One-time. Runs before anything reads, fills gaps only (never overwrites),
// then writes a sentinel and never reads this again.
migrateStorageFrom: asyncStorageAdapter(),
});Ship that in the release that changes storage, and delete the line a release
later. If one binary inits with more than one client id — a dev / staging /
production selector, say — keep it until every one of them has booted once on
every device, since each client id migrates its own project-scoped keys.
One binary, several projects
If your app picks a project at runtime and passes a different clientId per
build channel, note which keys are shared and which are not (the Scope column
above).
Per client id, because the value belongs to a project: the offline queue and
the flag cache. This matters more than it sounds — a queued event carries a
profileId minted under one project, and flushing it under a different
klyfa-client-id attaches that profile to the wrong project. An in-place update
that flips the selector (same bundle id, so the data container survives) is
enough to trigger it. The SDK namespaces these for you as of 0.11.0; do not
also prefix them in your adapter, or the keys get namespaced twice and your
existing data is stranded.
Shared across every client id, because the value belongs to the device:
the install id, the push token, and the attribution result. Splitting those
would be actively harmful rather than merely wasteful — three install ids means
three anonymous identities for one phone, a per-project push token slot breaks
detach() after a channel flip, and attribution is worst of all, because
POST /match consumes a click from an org-wide pool, so one install would
burn one click per project and could take a different user's. Leaving it in longer is inert rather than harmful, but it keeps the old
native module in your build, which is usually the thing you were removing.
These are the keys involved, worst-to-lose first:
| Key | Scope | Holds | If it is lost |
| --- | --- | --- | --- |
| @klyfa/install_id | device | The durable anonymous identity | A new anonymous actor; the pre-login stitch breaks. Unrecoverable. |
| @klyfa/push_token | device | The token, so clear() can detach | A shared device keeps the previous user's binding. |
| @klyfa/attribution | device | Resolved deep-link match | Re-resolves, consuming a second click from your org's pool. |
| @klyfa/queue | per client id | Up to 500 buffered events | Silent event loss, bounded. |
| klyfa.flags.cache | per client id | Last known flag values | Defaults until the first refresh; the kill-switch is fail-open until then. |
| @klyfa/offline_queue | device | A pre-0.11 backlog | Adopted into @klyfa/queue on first launch, then zeroed. Read-only. |
| @klyfa/attribution | Resolved deep-link match | Re-resolves; risks a duplicate attribution. |
| @klyfa/push_opens | Open-dedup memo | A few duplicate notification_opened events. |
| klyfa.appstore.appId.<bundleId> | iTunes id memo | One extra API call. |
Analytics
Klyfa.screenView('Home');
Klyfa.track('questionnaire_completed', {score: 42});
Klyfa.revenue(999, {product: 'premium_monthly'}); // amount in centsEvents are queued while offline (persisted through your storage), flushed on
reconnect and app foreground, and drained with bounded concurrency so a
large backlog can't trip the ingest rate limit. Delivery is durable but
not infinite: a transient failure (offline, rate limit) re-queues for
free, but an event the server keeps rejecting with a 5xx is dropped after a
few attempts, and the offline queue is capped at 500 events — beyond that the
oldest are dropped. This bounds memory and storage on a long-offline device;
it is not an at-least-once delivery guarantee.
Both drops always warn to the console — neither hides behind debug — and
both are reportable, so silent loss is visible in production rather than only
in a debug build:
await Klyfa.init({
apiUrl, clientId, storage,
onDropped: (payload, reason) => {
// reason: 'queue_overflow' | 'delivery_failed'
Sentry.captureMessage(`klyfa dropped an event: ${reason}`);
},
});Time on screen
Each screenView() starts a timer. When the user moves to another screen or
backgrounds the app, the SDK emits a screen_time event carrying the
departed screen's route and how long it was actually in front of the user,
in milliseconds. Time while the app is backgrounded is not counted, and the
transient inactive state (app switcher, incoming call) pauses the clock
without ending the visit.
This only works if you call screenView() on navigation — there is no
auto-tracking on native. The cost is one extra event per screen visit, and a
visit interrupted by backgrounding reports one segment per stretch of
attention, so total time on a screen is the sum of its durations. Stretches
under a second are folded into the next one rather than dropped. Turn it off
with init({trackScreenTime: false}).
screen_time is instrumentation rather than something the user did, so the
server leaves it out of bounce rate, session event counts, and screen-view
counts. It still shows up in the raw event stream.
Consent (GDPR)
Analytics can be gated on a consent decision while feature flags and the kill-switch stay live (they're strictly-necessary):
await Klyfa.init({apiUrl, clientId, consent: 'pending'}); // boot gated
// ...after the user decides:
Klyfa.setConsent('granted'); // deliver buffered + future events
Klyfa.setConsent('denied'); // drop buffered events and future onesWhile consent is pending, analytics events buffer in memory only —
nothing is sent and nothing is written to disk, so an app killed before the
decision retains no pre-consent analytics data. That includes the install id:
the durable device identifier is minted on setConsent('granted'), not at
init.
Flags are the exception, and it is deliberate. Feature flags and the
kill-switch are strictly-necessary, so FlagsAPI refreshes on init()
regardless of consent — meaning that even under pending or denied the SDK
makes one GET /api/flags and writes one key, the flag cache,
to your store. That request carries no distinct_id and the cache holds
flag keys and evaluated values only — no personal data, no identifier — which
is the basis for treating it as strictly-necessary. It is still a network call
and a disk write, so list it in your ROPA rather than meeting it for the first
time in an audit.
There is deliberately no build flag to switch this off. A build that can disable its own flag fetch is a build whose kill-switch can no longer reach it, which defeats the one control that exists for when you have lost control of the fleet.
denied drops the in-memory buffer, removes any persisted queue, and erases
the install id — so refusing after a previous session had consented takes the
identifier off the device rather than only stopping its use. The user's
distinct-id is withheld from flag evaluation until consent is granted.
Global Privacy Control is web-only — there is no navigator on native and no
equivalent OS signal — so on React Native setConsent() is the only gate.
@klyfa/web honors GPC where it exists.
Privacy note for your DPO: with consent granted, up to 500 events — including any
identify()traits like email/name — may sit buffered on the device while it is offline. They are written through whateverstorageyou passed, in plaintext, so if that buffer needs encryption at rest, pass an encrypted adapter (see Storage); AsyncStorage does not encrypt, MMKV takes a key. CallKlyfa.clear()on logout (and when servicing an Art-17 erasure) to drop the buffer regardless.
Identity — one call for everything
// After login: analytics profile + flag targeting + push ownership.
Klyfa.identify(user.id, {email: user.email});
// On logout: clears analytics identity, flag targeting, cached flags.
await Klyfa.clear();What identify() does to events from before the login
Calling identify() back-fills this user's earlier anonymous events onto the
profile, so the pre-login journey — first open, browsing, sign-up — lands on
the same person as everything after it. This is what keeps one human from
being counted as two actors in funnels and retention, and it is why you should
call identify() at login even with no traits to pass. A bare
Klyfa.identify(user.id) is meaningful on its own.
The stitch is server-side and happens on the identify call itself:
| Project identity mode | Scope of the back-fill |
| --- | --- |
| standard (the default; mobile) | the install id or the derived device id |
| authenticated | the install id alone, or — cookieless — the current session only |
In every mode it reaches 30 days back, and only touches events that are still unattributed. Two consequences worth designing around:
- A user who first opened the app more than 30 days before signing up keeps only the last 30 days of their pre-login journey. If your acquisition funnel spans longer than that, measure it from the install id rather than from the profile.
- The stitch is not reconstructable later. It runs once, at
identify(). Skip the call — or defer it past the window — and those events stay anonymous permanently; there is no backfill job to run afterwards.
Events buffered under consent: 'pending' are stitched correctly: they are
stamped with the install id when consent is granted and they flush, not when
they were queued.
On a shared device, Klyfa.clear() rotates the install id, so the next person
to log in cannot be linked to the previous user's install. Events the previous
user was already identified against are never re-attributed either — the
back-fill only touches events that are still unattributed.
What can still cross users is the window in between: in standard mode the stitch also matches on the derived device id, which is a hash of IP + user agent, so genuinely-anonymous activity on that device (or on another device behind the same NAT) in the last 30 days can be claimed by whoever identifies next. If that matters for your deployment — a clinic tablet, a shared workstation — set the project to authenticated identity mode (Project → Privacy → Identity mode), which drops the device-id leg and confines the stitch to one install or one session.
Push
After obtaining a device token from your push library:
Klyfa.push.register({token}); // binds to the identified profile, if anyRegister as soon as you have a token — you do not need to wait for login. A subscription starts anonymous and is bound to a profile when one is known, so the OS permission prompt does not have to sit inside your sign-up flow, and a returning user is reachable the moment they identify. An anonymous device is never reached by a profile-addressed send.
Klyfa.clear() detaches the device on logout for you — the registration and the
granted permission survive, only the identity is dropped, so the next person to
log in on a shared device is reachable without another prompt and does not
receive the previous user's notifications.
Klyfa.push.detach(); // drop the identity, keep the registration
Klyfa.push.unregister(token); // drop the registration entirely (rare)Identity verification (optional)
Your client ID ships inside your published app, so by default anyone who
extracts it can register their own device against one of your user IDs and
receive that person's notifications. To close that, have your backend sign a
short-lived JWT whose sub is the user ID, register the public key in
Klyfa under Project → Push identity, and pass the token at login:
Klyfa.identify(user.id, {email: user.email}, jwtFromYourBackend);
// Or set it independently — e.g. after a refresh:
Klyfa.setIdentityToken(jwtFromYourBackend);Tokens are short-lived by design, so handle rejection and fetch a fresh one:
Klyfa.init({
apiUrl, clientId,
onIdentityRejected: async ({profileId}) => {
// That's all. Supplying a fresh token re-attempts the registration that
// was rejected — you don't need to call register() again yourself.
Klyfa.setIdentityToken(await fetchKlyfaToken(profileId));
},
});The JWT needs sub (your user ID) and exp. Sign it with RS256 or ES256 —
the private key stays on your server, so Klyfa cannot mint a token for one of
your users. Klyfa.clear() drops the token along with the rest of the identity.
Anonymous registration never needs a token, so logged-out devices stay reachable for broadcast sends even with enforcement on.
Open tracking
Without this call your Open rate is not zero — it is unmeasured, and the
dashboard says so rather than showing 0%. Call trackOpen from wherever your
notification library reports a tap, passing whatever it hands you:
import messaging from '@react-native-firebase/messaging';
// Tapped while the app was running or backgrounded.
messaging().onNotificationOpenedApp((message) => Klyfa.push.trackOpen(message));
// Tapped from cold start.
messaging().getInitialNotification().then((m) => m && Klyfa.push.trackOpen(m));It accepts the raw payload, the remote message, an expo-notifications
response, or a bare id string — the notification id lives in a different place
on each platform (APNs puts it beside aps, FCM inside data, and each
library nests that differently), so the SDK digs it out rather than making you
fork on Platform.OS. Repeat calls for the same notification report once, which
matters because a cold start commonly delivers one tap through both callbacks
above.
This has to be an explicit call: the open callback belongs to whichever library
registered the platform delegate — expo-notifications,
@react-native-firebase/messaging, notifee — they are mutually exclusive, and
this package deliberately depends on none of them.
Two cases it cannot see, by construction:
- Foreground taps on Android. Sending a
notificationblock means the system does not post a tray notification while your app is in the foreground, so there is nothing to open. Display it yourself (e.g. notifee) if you need those counted. - iOS through Firebase messaging. Pushes go via APNs directly, so RNFB's
onNotificationOpenedAppnever fires for them on iOS. Useexpo-notificationsor@react-native-community/push-notification-iosthere.
There is no "delivered" state anywhere in the product: neither APNs nor FCM reports a delivery receipt, so sent means the provider accepted it.
Feature flags
const newOnboarding = Klyfa.flags.get('new_onboarding', false);
const variant = Klyfa.flags.get('checkout_variant', 'control');Reads are synchronous from an in-memory cache backed by your storage.
First boot returns the supplied default until the first refresh
completes (fire-and-forget on init). The SDK auto-refreshes on
AppState 'active'.
Scoping the kill switch by country
The kill switch is an ordinary flag — installKillSwitch() reads its
payload through flags.get() — so anything the rules engine can target, the
kill switch can be scoped by. A single global min_version_ios is what one
unconditional rule gives you, not what the mechanism imposes.
country is targetable, so one flag can carry different minimums:
| Rule | Condition | Value |
| --- | --- | --- |
| 0 | country in ['DK','DE','SE'] | {min_version_ios: '2.4.0'} |
| 1 | country eq 'CA' | {min_version_ios: '2.1.0'} |
| default | — | {} — inert, no forced update |
An app on 2.2.0 is blocked in Denmark, untouched in Canada, and untouched everywhere else.
One trap, and it is why this can look like it doesn't work: a country-scoped
rule only matches if you actually pass country (there is no device fallback —
see the country option above). A build that never sets it reaches no country
rule at all, silently.
Experiment exposures
Reading a flag automatically reports an exposure (which value the user
was shown) so you can tie a variant to downstream outcomes for A/B
analysis. Exposures are dedup'd per flag per user, batched, and
consent-gated (nothing is sent until consent is granted and a user is
identified). The kill-switch flag is never reported. Disable entirely with
trackExposures: false in init().
Deferred deep-link attribution
Ask which tracking-link an install came from (e.g. a paid-social campaign), and route the user accordingly:
const match = await Klyfa.attribution.resolve();
if (match.matched) {
Klyfa.track('campaign_matched', {campaign: match.campaign, source: match.source});
// ...route to the deep-linked destination
}resolve() is opt-in, app-driven, and consent-gated — it makes no
network call until consent is granted, so call it on first launch after the
user consents. Only a definitive server answer is persisted; a transient
failure (offline, no consent yet, install id not ready) is not cached, so
the next launch retries. It runs at most once per install thereafter, never
throws, and times out after 8s. Matching uses the Play Store install
referrer on Android and an IP + user-agent fingerprint on iOS (30-minute
window), so time it close to first launch.
Kill switch
Forces an app upgrade when the installed version falls below the minimum configured in the dashboard (Project → Flags → Kill switch).
// Anywhere after init — typically in App.tsx after navigation mounts.
Klyfa.flags.killSwitch();The SDK reads the kill-switch flag from cache, compares the installed
version (via DeviceInfo.getVersion()) against the platform-specific
minimum, and shows a non-cancelable alert with an "Update" button if the
user is out of date. The button opens the App Store / Play Store; if the
user dismisses without updating, the alert re-shows on app foreground.
On iOS, when no
appIdis supplied the SDK resolves the App Store track id via a one-time request to Apple's public iTunes lookup API (itunes.apple.com). That's a third-party egress from the device — disclose it in your privacy documentation, or passappIdto avoid it.
Safety model (read before configuring a minimum version)
The kill-switch is fail-open: if the flag hasn't loaded (offline first launch), the platform minimum isn't set, or the installed version can't be read, it does nothing. It only blocks when it can positively determine the app is below the configured minimum. An offline patient is never locked out of a working app.
Operational caution — a wrong minimum blocks the whole fleet. The minimum version is free-form in the dashboard flag. A typo (e.g.
10instead of1.0) will hard-block every install below it. Treat raising the minimum like a deploy: double-check the value. Recovery: lower the minimum in the dashboard; apps re-evaluate on the next foreground/flag refresh and stop blocking. Set the minimum only as high as the oldest version you truly must retire.
Migration from a hand-rolled config-URL pattern
- import * as killSwitch from './utils/kill-switch';
- killSwitch.init({
- configUrl: 'https://your-cdn.example/config.json',
- title: 'Update required',
- message: 'Please update to continue.',
- button: 'Update',
- });
+ Klyfa.flags.killSwitch({
+ title: 'Update required',
+ message: 'Please update to continue.',
+ button: 'Update',
+ });The dashboard now owns title/message/button (server-side overrides win when no client option is provided).
Migrating from 0.9
storage is now required — that is the whole breaking change, and it is one
line:
import {Klyfa} from '@klyfa/react-native';
+ import {asyncStorageAdapter} from '@klyfa/react-native/async-storage';
await Klyfa.init({
apiUrl, clientId,
+ storage: asyncStorageAdapter(),
});That keeps the exact behaviour 0.9 had, reading the same keys in the same
place, so nothing on the device moves and no migration is needed. Only reach
for migrateStorageFrom if you are switching to a different store — see
Changing stores.
Two behaviour changes come with it, neither requiring code:
- Analytics calls made before
init()no longer throw. They return thenullthe types always advertised, and warn once. If you wrappedtrack()in a guard for this, you can drop it. - Events buffered under
consent: 'pending'now carry the install id when they flush, so pre-consent activity stitches to the profile on login instead of falling back to the rotating IP+UA hash.
Migrating from 0.1 (analytics via a second SDK)
0.2 absorbs analytics — remove the second analytics package entirely:
- npm uninstall @openpanel/react-native- import {OpenPanel} from '@openpanel/react-native';
- export const op = new OpenPanel({apiUrl: 'https://api.klyfa.com', clientId: '...'});
+ // gone — Klyfa.init() covers analytics now
- op.screenView('Home');
+ Klyfa.screenView('Home');
- op.identify({profileId: user.id});
- Klyfa.flags.setDistinctId(user.id);
+ Klyfa.identify(user.id);
- op.clear();
- await Klyfa.flags.reset();
+ await Klyfa.clear();Buffered offline events persisted by the previous SDK are picked up and delivered after the upgrade (the queue is migrated on first launch).
App Store / Play privacy submission
Klyfa is first-party analytics and does not require App Tracking
Transparency (no IDFA, no cross-app tracking, no third-party sharing). For the
nutrition label, Play Data Safety, and a ready App Review answer, see the
privacy disclosure reference (docs/sdk-privacy-disclosure.md in the platform
repo).
License
MIT — see LICENSE. Includes MIT-licensed third-party code from the
OpenPanel SDK; see THIRD-PARTY-LICENSES.
