nohmo 0.4.1 copy "nohmo: ^0.4.1" to clipboard
nohmo: ^0.4.1 copied to clipboard

Official Nohmo analytics SDK for Flutter — device tracking, session journeys, install attribution, deep linking, crash reporting and event batching for iOS and Android.

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.

Install #

# pubspec.yaml
dependencies:
  nohmo: ^0.4.1
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, automatically, on every route change.
      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.


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.

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 #

MaterialApp(navigatorObservers: [Nohmo.observer])

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 #

For a go_router shell, a PageView, or anywhere the visible screen changes without a Navigator push:

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()),
  ],
)

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

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.

  1. Build a tracking link in Settings → App → Attribution Link Builder:
    https://www.nohmo.in/api/click/<project-code>/?utm_source=facebook&utm_medium=cpc&utm_campaign=summer
    
  2. 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 App Analytics → Install 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.

UTM params on your deep link are captured automatically:

yourapp://open?utm_source=meta&utm_medium=cpc&utm_campaign=summer

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 deferred links resolve during Nohmo.init. Nohmo.getDeepLink() returns the current destination synchronously if you would rather poll.

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 → Mobile → 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 as utm_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.
  • channelutm_medium, campaignutm_campaign, sourceutm_source (defaults to referral).

Uninstall detection #

Nohmo detects uninstalls with the same silent-push technique as AppsFlyer and Adjust.

1. Upload your Firebase Service Account JSON in Settings → App.

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 in App Analytics → Uninstalls 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
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
autoDeepLinks bool true Resolve Smart Link destinations from launch and runtime URLs
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());

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.


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 sessionDurationSecs, 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 source, medium, campaign
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_INSTALL in 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 5xx is a failure, not a delivery. Only a response the server actually accepted clears a batch; a 502 from a proxy re-queues it.
  • init() is idempotent. A hot restart or a double-mounted root cannot produce two APP_INSTALLs, two /identify calls or a leaked flush timer.
  • Transient lifecycle states are ignored. inactive and hidden fire 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; only paused and detached count.
  • Sessions and screens are timed separately. TIME_SPENT measures the screen; APP_BACKGROUND measures the session.
  • Bounded memory. The in-memory queue caps at 1000 events and the persisted tail at 500, so a device offline for days cannot grow the queue until the app is OOM-killed.
  • 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(), not commit() — 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 + 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 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 29/29
beta 3.48.0 / Dart 3.14.0 29/29

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.

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:

  • /identify reports 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, with INSTALL_ATTRIBUTED carrying raw utm_* keys;
  • autocapture names real controls correctly from real touches (ElevatedButton / "Send custom event", IconButton / "Back" from its semantic label, and a NohmoTracked name overriding the inferred one);
  • RAGE_CLICK fires once on the third rapid tap and not again;
  • backgrounding emits APP_BACKGROUND and 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 reported APP_CRASH with 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

0
likes
160
points
0
downloads

Documentation

API reference

Publisher

verified publishernohmo.in

Weekly Downloads

Official Nohmo analytics SDK for Flutter — device tracking, session journeys, install attribution, deep linking, crash reporting and event batching for iOS and Android.

Homepage
Repository (GitHub)
View/report issues

Topics

#analytics #attribution #tracking #deeplink #crash-reporting

License

MIT (license)

Dependencies

flutter, http

More

Packages that depend on nohmo

Packages that implement nohmo