nohmo
Official Nohmo analytics SDK for Flutter — device tracking, session journeys, screen views, install attribution, Smart Links, crash reporting and batched event delivery for iOS and Android.
One package, two platforms, no third-party plugins to add.
Finding the mobile settings. The dashboard has a Web / App switch next to your project name in the top bar. App stores, Deep linking, Nohmo Links and Uninstalls only appear in Settings while you are on the App side — if a tab named below is not there, flip that switch first.
Before you start
You need two values from your Nohmo dashboard.
- Sign in at nohmo.in and create a project.
- Open Settings → Setup. It shows your Project ID (
proj_…) and API key (pk_…).
Both are meant to ship inside your app — they only allow writing events to your project,
never reading your data. proj_xxxx and pk_xxxx below are placeholders.
Install
# pubspec.yaml
dependencies:
nohmo: ^0.5.0
flutter pub get
cd ios && pod install # iOS only
Quick start
import 'package:flutter/material.dart';
import 'package:nohmo/nohmo.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Nohmo.init(
projectId: 'proj_xxxx',
apiKey: 'pk_xxxx',
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
// Screen views + time spent on every named route change.
// Using go_router? See "Screen tracking" — the observer goes on GoRouter.
navigatorObservers: [Nohmo.observer],
// Every tap: PRESS, LONG_PRESS, RAGE_CLICK.
builder: (context, child) => NohmoAutocapture(child: child!),
home: const HomeScreen(),
);
}
}
That is the whole setup. Installs, opens, backgrounds, screen views, time spent, taps, rage taps, crashes and install attribution are all tracked from here.
You do not have to await Nohmo.init — the tracker exists the moment it
returns control, and any event sent before identity resolves is buffered and
stamped once the device id is known. Await it only if the very next thing you do
needs Nohmo.instance.deviceId.
Check it worked
Run the app and move between a couple of screens, then open Live Feed in the dashboard. Events appear within a few seconds.
Nothing there? The SDK reports failures with debugPrint rather than going quiet — look
for a line starting [Nohmo]:
| Console message | What it means |
|---|---|
Server rejected the SDK credentials (HTTP 401) |
projectId or apiKey is wrong. Copy both again from Settings → Setup. |
event delivery failed: HTTP 4xx |
Events reached the server and were refused; the status says why. |
| nothing at all | Nohmo.init never ran — check it is awaited in main(). |
Track custom events
Nohmo.send('purchase_started', {'itemId': item.id, 'price': item.price});
Events are queued in memory, written to disk, and flushed as a batch every
flushInterval. They survive the app being killed and never block the UI
thread.
Identify users after login
await Nohmo.linkUser(user.id, email: user.email, meta: {'plan': user.plan});
Every event fired before linkUser() — including across previous sessions — is
retroactively attached to the user on the backend. Nothing is lost. If the same
user calls linkUser() from a second device, the profiles merge.
Safe to call before Nohmo.init() has finished — it waits, then sends. If the
server refuses the link it says so on the console rather than returning quietly,
so a call that looks like it worked really did:
| Console message | Cause |
|---|---|
Server rejected the SDK credentials (HTTP 401) |
projectId/apiKey don't match a live key, or host points at the wrong server. Nothing is being recorded at all — check Dashboard → Settings → Setup. |
linkUser: the server does not know this device |
The initial identify never got through (usually offline on first launch). |
linkUser failed: HTTP 4xx |
The call reached the server and was rejected — the status says why. |
event delivery failed: HTTP 4xx |
A batch of events was refused. /track authenticates by body rather than header, so it can fail on its own. A 4xx is not retried — those events are gone — while a 5xx keeps them queued for the next flush. |
A link that doesn't get through is not lost: the SDK records which userId the server actually confirmed and re-sends it on the next start, so someone who logged in while offline is linked as soon as the app next reaches the network. A link already confirmed costs no request on later launches.
Track conversions
Define goals in Settings → Conversions, then:
Nohmo.trackConversion('user_created');
Nohmo.trackConversion('money_deposit', {'amount': 500, 'currency': 'USD'});
Attribution is automatic — a user who arrived via
?utm_source=google&utm_medium=cpc has that conversion credited to Google CPC
with no extra code.
Screen tracking
Automatic (recommended)
Which line you need depends on your router — the observer is the same either way.
// MaterialApp / CupertinoApp with Navigator routes
MaterialApp(navigatorObservers: [Nohmo.observer])
// go_router — MaterialApp.router has no navigatorObservers parameter,
// so the observer goes on the router instead
GoRouter(
observers: [Nohmo.observer],
routes: [ ... ],
)
Using
ShellRouteorStatefulShellRoute? A shell builds its ownNavigator, and a root-level observer does not see pushes inside it. Pass the observer to each shell as well, or screens inside the shell never report:ShellRoute( observers: [Nohmo.observer], builder: (context, state, child) => AppScaffold(child: child), routes: [ ... ], )This is the one setup that fails quietly — the app compiles, some screens report, and the rest silently keep the last screen name that did.
Screen names come from RouteSettings.name. With Navigator.pushNamed('/cart')
that is free; for manually constructed routes, name them:
Navigator.of(context).push(MaterialPageRoute(
settings: const RouteSettings(name: 'ProductDetail'),
builder: (_) => const ProductDetailScreen(),
));
Routes with no name are skipped rather than reported as
_ModalScopeState. Dialogs, snackbars and other PopupRoutes are skipped too —
they are not screens, and reporting them would shred the journey.
Need different names? Pass an extractor:
NohmoNavigatorObserver(
nameExtractor: (route) => route.settings.name ?? route.runtimeType.toString(),
)
Screens that are not routes
Some screens never involve a Navigator push at all, so no observer can see them — tabs,
a PageView, an IndexedStack, or anything that swaps the visible widget in place. Name
those yourself:
NohmoScreen(name: 'Cart', child: CartView())
IndexedStack builds every child, not just the visible one, so pass
active there or all your tabs report a view the moment the nav bar is built:
IndexedStack(
index: _index,
children: [
NohmoScreen(name: 'Home', active: _index == 0, child: const HomeView()),
NohmoScreen(name: 'Cart', active: _index == 1, child: const CartView()),
],
)
How you'll know if screen tracking isn't working
Screen tracking failing is invisible from the outside — events keep flowing, they are just all stamped with the screen the user started on. So the SDK checks its own event stream and warns you in a debug build:
[Nohmo] Screen tracking does not look wired up.
31 events this session but only 1 SCREEN_VIEW (Splash), so every event is being
stamped with that screen and TIME_SPENT will never fire.
The warning names a fix for each router shape, because it cannot know which one you use.
It fires once per session, only when kDebugMode is true, and never appears in a release
build.
It catches every cause of the same symptom: a missing observer, the observer on
MaterialApp in a go_router app, a ShellRoute without its own observer, or routes
pushed with no RouteSettings.name.
If your app genuinely has one screen, or you deliberately don't track screens, turn it off:
Nohmo.init(projectId: '…', apiKey: '…', setupWarnings: false);
You can also check by eye: open Live Feed in the dashboard and navigate around your
app. If the Page column never changes, screen tracking is not wired.
Manual
Nohmo.trackScreenView('Checkout');
Tap autocapture
MaterialApp(
builder: (context, child) => NohmoAutocapture(child: child!),
)
| Event | Trigger |
|---|---|
PRESS |
Any tap that lands on a live handler |
LONG_PRESS |
A press held for 500 ms or more |
RAGE_CLICK |
Three taps on the same control within a second |
Each event carries:
| Field | Meaning |
|---|---|
component |
The name a person would use — ElevatedButton, or your NohmoTracked name |
text |
The visible label, including an icon button's tooltip or semantic label. Redacted — see below |
handler |
The widget that actually holds the tap handler, e.g. GestureDetector |
selector |
Containment path, e.g. Scaffold > CheckoutCard > ElevatedButton — this is what Silent Failures shows you for a dead press |
Labels and personal data
A label is whatever the control happens to display, and plenty of controls
display the user's own data — a contact row, an order total, an account number.
So text is redacted by default: emails, phone numbers, card- and
account-length digit runs and currency amounts are replaced with a placeholder,
keeping the wording that makes the report readable.
"Pay priya.sharma@gmail.com ₹48,210" -> "Pay [email] [amount]"
Shape-matching cannot catch a bare name, an address or a diagnosis. Wrap those:
NohmoRedacted(
child: ListTile(title: Text(patient.name), onTap: open),
)
The tap is still reported — component, handler and selector are
unaffected — it simply carries no label. To send no labels anywhere, use
NohmoAutocapture(captureText: false, child: …); to turn the redaction off and
send labels verbatim, redactText: false.
Text fields report a tap, because a form funnel with no "tapped into the email field" step has a hole exactly where people drop out. The value is never read — only the field's own hint or label.
Disabled controls report nothing, and neither does anything behind an
IgnorePointer or AbsorbPointer. A tap that could not have done anything is
not a dead press.
How it works. The React Native SDK rewrites your source at build time with a
Babel plugin. Flutter has no equivalent, so NohmoAutocapture watches raw
pointer events at the root of your app and, on each tap, walks the render tree
down to the tap point to find which widget was actually hit. It recognises
GestureDetector, InkWell/InkResponse, every Material and Cupertino button,
ListTile, Switch, Checkbox, Radio, Slider, chips and dropdowns — and
because virtually every third-party button is built on one of those, they are
covered too.
Naming. The widget holding the handler is rarely the one you'd name: Flutter
builds an ElevatedButton down through more than thirty elements before
reaching the GestureDetector that owns onTap. So the reported component is
the outermost interactive widget that is still the same control, judged by
size — a button and the InkWell inside it occupy the same box, while a
GestureDetector wrapped around a whole list does not. That rule needs no list
of framework type names to keep up to date, which matters because those change
between Flutter releases.
Only real taps are reported. A tap on background padding, a scroll, a swipe,
and a tap on a disabled button all produce nothing. That is not just noise
control: Nohmo's dead-press detection treats every PRESS as something the user
could reasonably expect to act, so a tap on empty space would manufacture a dead
press that never happened.
Override the inferred name where it is not the one you want in reports:
NohmoTracked(
name: 'checkout_pay',
child: ElevatedButton(onPressed: pay, child: const Text('Pay')),
)
Privacy. Button labels can carry personal data — a name, an email, an amount. Turn text capture off and only structure is reported:
NohmoAutocapture(captureText: false, child: child!)
A tap that leads to nothing — no navigation, no custom event, no request — is detected server-side as a dead press and shown under Silent Failures.
Crash & error reporting
On by default. Nothing to wire up.
| Event | Source |
|---|---|
JS_ERROR |
Flutter framework errors (FlutterError.onError) and uncaught Dart errors (PlatformDispatcher.onError) |
APP_CRASH |
Native crashes — Android Java/Kotlin uncaught exceptions; iOS NSException, Swift fatalError, force-unwraps and signals (SIGSEGV, SIGABRT, …) |
The split is deliberate. An uncaught Dart error does not abort the Flutter
process the way a fatal JS error aborts React Native's, so it reports as
JS_ERROR. APP_CRASH means the app really died.
Native crashes cannot do network I/O — the process is going away — so they are persisted natively and reported on the next launch, attributed back to the session, screen and timestamp they actually happened in. They land in the right journey, not at the top of the next one.
Report a caught error yourself:
try {
await riskyThing();
} catch (e, stack) {
Nohmo.recordError(e, stack, context: 'checkout');
}
Your existing handlers still run — Nohmo chains rather than replaces them, so the red screen in debug, Crashlytics, and Play Console all still work.
Install attribution
Nohmo uses the same deterministic mechanism as AppsFlyer and Adjust. Zero code needed in your app — the SDK reads the referrer on first open automatically.
- Build a tracking link in Settings → Nohmo Links:
https://www.nohmo.in/api/click/<project-code>/?utm_source=facebook&utm_medium=cpc&utm_campaign=summer - Use it in your ad. Nohmo records the click and routes to the right store:
- Android — the click UUID rides the Play Store referrer param, which Google Play delivers on first open.
- iOS — a brief interstitial writes the UUID to the system pasteboard; the SDK reads and clears it on first open.
| Priority | Method | Accuracy |
|---|---|---|
| 1 (Android) | nohmo_click UUID in the Play Store referrer |
Deterministic |
| 1 (iOS) | nohmo_click UUID in the system pasteboard |
Deterministic |
| 2 | GAID / IDFA match | Deterministic |
| 3 | UTMs in the referrer (no click ID) | High |
| 4 | IP + platform within 24 h | Probabilistic |
| 5 | No match | Organic |
Results appear in Attribution.
iOS note. The SDK reads the pasteboard exactly once, on the first launch after install, and only when it already contains a string. Users who never tapped a Nohmo click link never see the "Pasted from Safari" banner.
Attribution via deep links
UTM params on your deep link are captured automatically:
yourapp://open?utm_source=meta&utm_medium=cpc&utm_campaign=summer
Smart Links — deep & deferred deep linking
A Nohmo Smart Link (https://www.nohmo.in/s/<projectId>?dlv=<destination>)
routes everyone to the right place from one URL:
- App installed → opens the app directly at
<destination> - New user → sends them to the store, then the SDK restores
<destination>after install (deferred deep linking)
class _AppState extends State<App> {
StreamSubscription<NohmoDeepLink>? _sub;
@override
void initState() {
super.initState();
_sub = Nohmo.deepLinks.listen((link) {
// link.value is your "Destination" field, e.g. "product/123"
final parts = link.value.split('/');
navigatorKey.currentState?.pushNamed('/${parts.first}', arguments: parts.last);
});
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
}
A destination resolved before you subscribe is replayed to a new listener, so
subscribing from initState cannot miss a deferred deep link — which matters,
because deep links resolve during Nohmo.init, before any of your widgets
exist. Nohmo.getDeepLink() returns the current destination synchronously if
you would rather poll.
link.source tells the two cases apart and is accurate for both:
NohmoDeepLinkSource.direct when the app was already installed and opened by
the link, deferred when it was restored after an install. Branch on it if a
deferred arrival should skip onboarding.
Call Nohmo.consumeDeepLink() once you have navigated. The replay is
per-subscriber and unbounded otherwise, so an app that subscribes from a screen
that remounts keeps being handed a destination it already handled:
_sub = Nohmo.deepLinks.listen((link) {
navigateTo(link.value);
Nohmo.consumeDeepLink();
});
Tapping the same link again — two friends sharing one product URL — resolves again and re-notifies, so the app navigates every time. Only a redelivery of one tap by the platform within a second is collapsed.
One-time setup for direct open
Deferred deep linking works out of the box. To make an already-installed app open directly:
1. Dashboard — fill in Settings → Deep linking: your iOS App ID
(TEAMID.bundle.id), Android package, and SHA-256 signing fingerprints. Nohmo
publishes the association files automatically.
2. iOS — Xcode → Signing & Capabilities → Associated Domains:
applinks:www.nohmo.in
3. Android — add to your launch activity in AndroidManifest.xml:
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="www.nohmo.in"
android:pathPrefix="/s/YOUR_PROJECT_ID" />
</intent-filter>
The SDK reads the launch intent and listens for links delivered while the app
runs — no app_links, uni_links or equivalent plugin needed. If your app
already owns URL handling, set autoDeepLinks: false and forward URLs yourself:
Nohmo.handleUrl(url);
Invite a friend (referral attribution)
Share a Nohmo link rather than the raw store URL and installs are attributed back to the user who shared:
final link = await Nohmo.buildInviteLink(channel: 'whatsapp');
// -> https://www.nohmo.in/api/l/aB3xK9q
await Share.share('Join me on the app! $link');
- Call
linkUser()first — the sharer's id is captured asutm_content. Without it the link is a generic referral link with no referrer. - Returns a short URL. The same user + options always resolve to the same code, and it is cached, so repeated shares never create duplicate links. Offline, it falls back to the full click URL.
channel→utm_medium,campaign→utm_campaign,source→utm_source(defaults toreferral).
Uninstall detection
Nohmo detects uninstalls with the same silent-push technique as AppsFlyer and Adjust.
1. Upload your Firebase Service Account JSON in Settings → Uninstalls.
2. Register the FCM token:
final token = await FirebaseMessaging.instance.getToken();
if (token != null) await Nohmo.registerPushToken(token);
// Handle rotation
FirebaseMessaging.instance.onTokenRefresh.listen(Nohmo.registerPushToken);
Every night at 03:00 UTC, Nohmo sends a silent data-only FCM message to devices
that have not opened the app in 24 h. NotRegistered means uninstalled.
Results land on the Dashboard (App surface) with D1/D7/D30 retention.
Accuracy: ~85–90% — users with notifications disabled cannot be detected (the same limitation every major analytics SDK has).
Options
| Option | Type | Default | Description |
|---|---|---|---|
projectId |
String |
— | Project code from the dashboard |
apiKey |
String |
— | Publishable API key (pk_…) |
appVersion |
String |
from the app bundle | Version sent with every event; feeds the release timeline |
flushInterval |
Duration |
5s |
How often batches are delivered. Duration.zero disables the periodic timer — see Testing |
sessionTimeout |
Duration |
30m |
How long the app may sit in the background before returning counts as a new session. Duration.zero starts one on every foreground |
debug |
bool |
false |
Log SDK activity with debugPrint |
autoAppLifecycle |
bool |
true |
APP_OPEN / APP_BACKGROUND on foreground/background |
autoErrors |
bool |
true |
Capture Flutter/Dart errors and native crashes |
autoInstallAttribution |
bool |
true |
Read the install referrer on first open |
iosPasteboardAttribution |
bool |
true |
Read the iOS pasteboard once, on first launch, for deterministic attribution. Set false to never show the "Pasted from …" banner — see Privacy |
autoDeepLinks |
bool |
true |
Resolve Smart Link destinations from launch and runtime URLs |
setupWarnings |
bool |
true |
Debug-build setup checks in the console (currently: screens never changing). Set false for a single-screen app, or one that deliberately does not track screens |
storage |
NohmoStorage? |
native | Where identity and the queue persist |
host |
String |
https://www.nohmo.in |
Ingestion host (self-hosted only) |
httpClient |
http.Client? |
own client | Transport override — inject a MockClient to assert on what the SDK sends, or a configured client for a proxy or certificate pinning |
appVersion is read from versionName (Android) and
CFBundleShortVersionString (iOS) when you leave it empty, so the release
timeline works without you passing it.
Storage
By default the SDK persists to Android SharedPreferences / iOS
NSUserDefaults through its own platform channel — no shared_preferences
dependency. To back it with something else, implement NohmoStorage:
class PrefsStorage implements NohmoStorage {
@override
Future<String?> getItem(String key) async =>
(await SharedPreferences.getInstance()).getString(key);
@override
Future<void> setItem(String key, String value) async =>
(await SharedPreferences.getInstance()).setString(key, value);
}
await Nohmo.init(projectId: '…', apiKey: '…', storage: PrefsStorage());
Surviving a reinstall
SharedPreferences and NSUserDefaults both live inside the app container,
which each platform deletes when the app is uninstalled. On its own that means a
reinstall looks like a device Nohmo has never seen: the install is counted again,
and a user who had logged in comes back anonymous.
To close that gap the SDK reads a reinstall-durable id from its own plugin and
sends it as stableId, which is what the backend matches a returning device on:
| Platform | Source | Resets when |
|---|---|---|
| iOS | A random UUID in the Keychain, ThisDeviceOnly so it never syncs to another device via iCloud |
The device is erased |
| Android | SHA-256 of ANDROID_ID salted with your package name — no raw hardware id leaves the device |
Factory reset |
Nothing to install or configure; it ships with the plugin. It is native code, so
a flutter run against an already-built app is not enough — do a clean build
(flutter clean && flutter run, or pod install for iOS) after upgrading.
Where the platform has nothing to offer — desktop, a device with no usable
ANDROID_ID, a Keychain write that failed — the SDK sends no stableId and
a reinstall starts a new device, exactly as before. It never substitutes a
guessed fingerprint: a value derived from model and screen size would collide
across identical handsets and merge two people into one device.
Privacy and App Store compliance
iOS privacy manifest. The SDK ships its own PrivacyInfo.xcprivacy, wired
into both CocoaPods and Swift Package Manager. It declares the two
required-reason APIs Nohmo calls — NSUserDefaults (CA92.1) and file
timestamps on its own crash records (C617.1) — and the data it collects
(device ID, user ID, email, product interaction, crash data; analytics purpose,
no tracking). Without it your upload comes back with ITMS-91053, Missing API
declaration, because you cannot declare on an SDK's behalf what you do not
know it calls.
NSPrivacyTracking is false and NSPrivacyTrackingDomains is empty: Nohmo
does not link data to third-party data for advertising, so no ATT prompt is
required. Reflect the collected-data list in your App Store Connect privacy
answers.
Android Data Safety. Declare Device or other IDs. The reinstall-durable id
is a SHA-256 of ANDROID_ID salted with your package name — the raw value never
leaves the device — and on iOS it is a random UUID in the Keychain, not a
hardware or advertising identifier.
The iOS pasteboard read. On the very first launch only, the SDK reads the
pasteboard once to recover a Nohmo click token, which is what makes iOS install
attribution deterministic rather than a guess. iOS 14+ shows a "Pasted from …"
banner whenever it does — including for users who never tapped a Nohmo link,
because the value has to be read before it can be recognised as ours. Set
iosPasteboardAttribution: false to skip it entirely and fall back to
probabilistic IP matching.
Tap labels. text on PRESS is redacted by default; see
Labels and personal data. For an app built largely
out of user data — health, banking, messaging — prefer
NohmoAutocapture(captureText: false, …).
Testing your instrumentation
Inject a MockClient and assert on exactly what the SDK would send — no
network, no platform channels:
import 'package:http/testing.dart';
final sent = <Map<String, dynamic>>[];
await Nohmo.init(
projectId: 'proj_test',
apiKey: 'pk_test',
storage: MemoryNohmoStorage(),
httpClient: MockClient((req) async {
final body = jsonDecode(req.body);
if (body is Map && body['events'] is List) {
sent.addAll((body['events'] as List).cast<Map<String, dynamic>>());
}
return http.Response('{"success":true}', 200);
}),
);
// ... drive your UI ...
await Nohmo.flush();
expect(sent.where((e) => e['event'] == 'CONVERSION'), isNotEmpty);
Note that flutter test installs an HttpOverrides mock that answers every
real request with a 400, so without an injected client your events look
delivered and vanish.
In a testWidgets test
Pass flushInterval: Duration.zero. The periodic flush timer otherwise
outlives the widget tree, and Flutter asserts on that before tearDown can shut
the SDK down:
A Timer is still pending even after the widget tree was disposed.
With the timer off, you drive delivery yourself with Nohmo.flush(). Because
init() and flush() do real I/O, run them inside tester.runAsync:
testWidgets('checkout reports a conversion', (tester) async {
await tester.runAsync(() => Nohmo.init(
projectId: 'proj_test',
apiKey: 'pk_test',
storage: MemoryNohmoStorage(),
flushInterval: Duration.zero, // no timer to leak
httpClient: MockClient(...),
));
addTearDown(Nohmo.shutdown);
await tester.pumpWidget(const MyApp());
await tester.tap(find.text('Pay'));
await tester.pumpAndSettle();
await tester.runAsync(() => Nohmo.flush());
expect(sent.where((e) => e['event'] == 'CONVERSION'), isNotEmpty);
});
What gets tracked automatically
| Event | Trigger | Data |
|---|---|---|
APP_INSTALL |
Very first open after install | platform, appVersion, osVersion |
APP_OPEN |
Launch and every return to foreground | platform, appVersion |
APP_BACKGROUND |
App goes to background — may fire several times in one session | sessionDurationSecs (cumulative foreground time; carried for SDK parity, ingestion derives duration from TIME_SPENT instead), screen |
SCREEN_VIEW |
Route change or NohmoScreen |
screen |
TIME_SPENT |
Leaving a screen | screen, seconds |
PRESS |
Tap on an interactive widget | component, text, handler, selector |
LONG_PRESS |
Press held ≥ 500 ms | same as PRESS |
RAGE_CLICK |
Three taps on one control within a second | same as PRESS |
JS_ERROR |
Flutter framework or uncaught Dart error | message, stack, isFatal, screen |
APP_CRASH |
Native crash, reported next launch | kind, message, stack, signal, crashedAt |
INSTALL_ATTRIBUTED |
Install matched to a click | utm_source, utm_medium, utm_campaign, nohmo_click |
DEEP_LINK |
Smart Link destination resolved | value, source |
USER_LINKED |
linkUser() |
userId, email |
CONVERSION |
trackConversion() |
slug, plus your properties |
Reliability
The parts that are easy to get wrong, and how this SDK handles them:
- The queue survives being killed. Events are written to disk (throttled to
once a second, immediately on backgrounding and on a fatal error) and restored
on the next launch with their original timestamps.
APP_INSTALLin particular is made durable before the first-open flag is written, so a cold start on a cold network cannot silently lose an install. - A retryable failure keeps the events. 5xx, 408, 425 and 429 re-queue the batch; only a response the server actually accepted clears it, so a 502 from a proxy is not mistaken for success. A 400 or a rejected key is dropped rather than retried forever — and reported, because a batch dropped silently is how a whole integration goes quiet.
- Retries back off. Exponential from 5s to a 5-minute ceiling, with jitter,
so a device offline for hours is not waking its radio every flush interval. An
explicit
flush()— including the one on backgrounding — ignores the backoff. init()is idempotent. A hot restart or a double-mounted root cannot produce twoAPP_INSTALLs, two/identifycalls or a leaked flush timer.- Transient lifecycle states are ignored.
inactiveandhiddenfire when the user opens Control Centre or a permission sheet appears. Treating those as backgrounding would mint a new session each time and shred real sessions into one-event fragments; onlypausedanddetachedcount. - A short absence is not a new session. Reading an OTP, approving a payment,
picking a photo — returning within
sessionTimeout(30 minutes by default) resumes the same session, so a checkout funnel is not split in two and session counts are not inflated. - The screen you leave on records its time.
TIME_SPENTfires on backgrounding as well as on a screen change, so the last screen of a session — usually the interesting one — is measured. Time spent in the background is not billed to it. - A failed
linkUser()or push-token registration is retried on the next start until the server confirms it, so a login or a token on a cold network is not lost. - Storage failures cannot take the SDK down. A
NohmoStoragethat throws (secure storage on a locked device) degrades to in-memory identity for the run instead of leaving the tracker without a device id and silently buffering every event for the life of the process. - Sessions and screens are timed separately.
TIME_SPENTmeasures the screen;APP_BACKGROUNDmeasures the session. - Bounded memory, and it says so. The in-memory queue and the persisted tail
cap at the same 1000 events, so a device offline for days cannot grow the queue
until the app is OOM-killed — and nothing is trimmed silently between two
different caps. Dropping is reported with
debugPrint. - Nothing here can crash or stall your app. Every platform-channel call
degrades to a null result when the native side is missing and is bounded by
a timeout, because
init()awaits several of them before the first event — and a native side that never calls back (an iOS pasteboard read behind a busy main queue, say) would otherwise hang startup. The error handlers cannot throw, and they chain to yours rather than replacing them. - Storage writes stay off the critical path. Android uses
SharedPreferences.apply(), notcommit()— the queue is written about once a second, and a synchronous disk write of a growing JSON blob on the platform thread is an ANR waiting to happen on a device that has been offline.
Platform support
| Android | iOS | |
|---|---|---|
| Minimum | API 21 | iOS 12 |
| Events, sessions, screens, taps | ✅ | ✅ |
| Install attribution | Play Install Referrer | Pasteboard click token |
| Native crash capture | Java/Kotlin uncaught† | NSException + fatal signals |
| Deep links | App Links + custom scheme | Universal Links + custom scheme |
| Dependency manager | Gradle (KGP and Built-in Kotlin) | CocoaPods and Swift Package Manager |
† Android captures uncaught JVM exceptions. Crashes inside native
(NDK/C++) code and ANRs are not captured on Android; iOS's signal handlers
do cover the equivalent. Errors thrown in a background isolate are not
captured on either platform — PlatformDispatcher.onError is per-isolate, so a
Workmanager or flutter_background_service callback needs its own
Nohmo.recordError.
Android requires AGP 7.3+ (Kotlin plugin version is taken from your app's
kotlinVersion if it sets one).
The SDK compiles for web, macOS, Windows and Linux — events, screens and taps
work there — but install attribution, native crash capture and deep links are
Android/iOS only, and identity falls back to in-memory storage unless you supply
a NohmoStorage.
Keeping this working on future Flutter versions
Flutter ships a stable release roughly quarterly, and the changes that break a plugin are predictable in kind: a framework API is renamed or removed, or the Android Gradle toolchain moves. Both are cheap to catch and expensive to discover from user bug reports.
Run one command after every upgrade
cd nohmo
./tool/verify.sh # analyze + test + publish check (~1 min)
./tool/verify.sh --full # + real APK on both Kotlin paths
# + iOS sources compiled (~5 min)
--full needs example/android and example/ios; if they are missing, run
(cd example && flutter create --platforms=android,ios .) once.
Watch beta, not stable
Breaking changes reach beta about one release before stable. The CI workflow
in .github/workflows/flutter.yml runs the
whole matrix — analyze, test, Android on both Kotlin paths, iOS sources —
against stable and beta, on every push and weekly on a schedule. The
schedule is the part that matters: beta moves whether or not anyone touches
this repo, so a cron job is what turns "users upgraded and we broke" into "CI
told us six weeks ago."
To check beta by hand:
flutter channel beta && flutter upgrade
cd nohmo && ./tool/verify.sh --full
flutter channel stable && flutter upgrade # switch back
What is already future-proofed, and why
| Risk | How it is handled |
|---|---|
WidgetsBindingObserver became an abstract mixin class in 3.13 |
The tracker extends a small observer rather than mixing it in — valid on every version |
AppLifecycleState.hidden added in 3.13 |
Handled with a default: branch, so new states cannot break the switch |
Radio.onChanged deprecated in 3.32 |
Not read; a Radio is simply treated as tappable |
| Kotlin Gradle Plugin deprecated for plugins (AGP 9) | Applied only when built-in Kotlin is off, using the same test as Flutter's own Gradle plugin, and applied via pluginManager so Flutter's source scan does not flag it |
| Java level moving from 11 to 17 | Derived from the AGP in use, not pinned |
| CocoaPods being replaced by Swift Package Manager (Flutter already warns this "will become an error"; the CocoaPods specs repo goes read-only in Dec 2026) | ios/nohmo/Package.swift ships alongside the podspec, both reading the same sources — verified by a real flutter build ios under each |
| Flutter changing how it builds a button | Autocapture names controls by size, not by a list of framework widget names |
| Crash reporting silently breaking | readAndClear takes its Context as an argument, so it cannot depend on the order Dart happens to call the native side in — the shape of the bug found on-device |
| Icon fonts / new Material internals | Labels reject private-use-only strings rather than allow-listing widgets |
The one thing that will need a manual bump
http is pinned >=0.13.0 <2.0.0. If http 2.0 ships and your app needs it,
resolution will fail until this constraint is widened. It is the SDK's only
third-party dependency, and httpClient lets you inject your own transport in
the meantime.
Verified on
| Channel | Version | analyze | tests | Android (both Kotlin paths) | iOS sources |
|---|---|---|---|---|---|
| stable | 3.47.1 / Dart 3.13.1 | ✅ | 72/72 | ✅ | ✅ |
| beta | 3.48.0 / Dart 3.14.0 | ✅ | 72/72 | ✅ | ✅ |
iOS was additionally built end to end (flutter build ios) under both
CocoaPods and Swift Package Manager, with NohmoPlugin and NohmoCrash
confirmed present in the linked binary rather than trusting a green build.
Read that iOS column precisely: it is a compile of the plugin's Objective-C
against the real iOS SDK, not a run. That gap is exactly how the UIScene deep
link break in 0.5.0 survived to release — the sources compiled perfectly, and
every link callback had simply stopped being called on a migrated host app. The
release invariants CI job now asserts the scene registration and callbacks
exist, and iOS deep linking is worth re-checking on a device after any Flutter
iOS lifecycle change.
The SDK has also been run on a physical Android device (Redmi Note 7 Pro, Android 16 / API 36, arm64) against a local ingestion server, confirming at runtime — not just at build time — that:
/identifyreports real device facts (logical screen size, pixel ratio, locale, IANA timezone, and the app version read from its own bundle);- the native Play Install Referrer is read on first open and reaches
/attribute, withINSTALL_ATTRIBUTEDcarrying rawutm_*keys; - autocapture names real controls correctly from real touches
(
ElevatedButton/ "Send custom event",IconButton/ "Back" from its semantic label, and aNohmoTrackedname overriding the inferred one); RAGE_CLICKfires once on the third rapid tap and not again;- backgrounding emits
APP_BACKGROUNDand returning mints a new session; - the queue survives the process being killed — events generated while delivery was failing were force-stopped out of memory and arrived on the next launch with their original timestamps;
- native crash capture works end to end — a real uncaught JVM exception
(
FATAL EXCEPTION: main) killed the process, and the next launch reportedAPP_CRASHwith the full stack, thread, and the session and screen the crash happened on rather than the one that reported it.
That last one is worth dwelling on, because it is the case unit tests could not reach. The Android crash store used to resolve its directory from a Context captured when the crash handler was installed — but the SDK drains the previous run's crashes before installing this run's handler, so the read came back empty every time and crash records piled up on disk, unreported. Everything built, analysed and unit-tested green throughout. Only crashing a real app on a real device surfaced it.
Other Nohmo SDKs
| Platform | Package |
|---|---|
| React / Next.js / plain HTML | nohmo |
| React Native | nohmo/react-native |
| Node / server | nohmo/server |
| Flutter | this package |
License
MIT
Libraries
- nohmo
- Official Nohmo analytics SDK for Flutter.