nohmo 0.6.0
nohmo: ^0.6.0 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.
0.6.0 #
A full audit of 0.5.0 against real user behaviour — what a person actually does with a phone — rather than against the code. Two of these stopped iOS working outright; the rest were wrong data that looked like right data.
Fixed — iOS #
- Deep linking was completely dead on Flutter 3.47+. The plugin registered
only for
UIApplicationDelegatecallbacks. Once a host app adopts theUIScenelifecycle — Flutter's migration is enabled by default from 3.47 and rewrites the templateAppDelegateautomatically — UIKit stops callingapplication:openURL:options:andapplication:continueUserActivity:entirely, and per Flutter's migration guide thelaunchOptionspassed todidFinishLaunchingWithOptionsbecomenil. All three link entry points went quiet at once: no direct opens, no Universal Links, no deferred deep linking and no launch-URL attribution, on every iOS install, with no error anywhere. The plugin now also registers as a scene delegate and implementsscene:willConnectToSession:options:,scene:openURLContexts:andscene:continueUserActivity:, while keeping the application-delegate path for apps that have not migrated. Both returnNOso the host app's own URL handling still runs. - Added the privacy manifest.
PrivacyInfo.xcprivacynow ships through both CocoaPods and Swift Package Manager, declaring the required-reason APIs the SDK calls (NSUserDefaults, file timestamps) and the data it collects. Without it an App Store upload comes back with ITMS-91053, because an app cannot declare on an SDK's behalf what it does not know the SDK calls. - A transient Keychain failure no longer destroys the stable device id.
Any non-success from
SecItemCopyMatchingfell through to delete-and- regenerate — includingerrSecInteractionNotAllowed, which is what a launch into the background before the device's first unlock returns. That threw away the real reinstall-matching identity and split one person into two devices, over a failure that would have resolved itself on the next launch. Only a genuinely absent or unreadable item is replaced now.
Fixed — deep links #
- Tapping the same link twice did nothing the second time. Destinations were de-duplicated by value for the lifetime of the process, and the value was restored from storage, so across launches too. Two friends sharing one product URL, or re-opening a saved message, produced no event and no stream emission, and the app never navigated. Only a redelivery of one tap by the platform, within a second, is collapsed now.
- A direct open was reported to the app as
deferred. The replay to a late subscriber hardcoded the source — and because a link resolves insideinit(), before any app widget exists, the replay is the only way an app ever sees a cold-start link. Anything branching onsourcedid the wrong thing on every cold start. The source is carried and persisted with the destination. - Launch-URL attribution now survives a restart. Session attribution was memory-only, so session 1 of a campaign install was attributed and session 2 looked organic.
- Added
Nohmo.consumeDeepLink(). The replay is per-subscriber and was otherwise unbounded, so an app subscribing from a screen that remounts kept being handed a destination it had already navigated to.
Fixed — sessions and screens #
- A two-second app switch no longer splits the session. Every foreground
minted a new session id, so reading an OTP, approving a payment or picking a
photo ended one session and started another. Checkout funnels spanned two
sessions and session counts were inflated. New
sessionTimeoutoption, 30 minutes by default (Duration.zerorestores the old behaviour). - The screen the user backgrounds from now records
TIME_SPENT. It only ever fired on a screen change, so the last screen of every session — usually checkout, the paywall or the signup form — recorded no time at all. Time spent in the background is no longer billed to the screen either. - Back-navigation no longer corrupts the screen name.
didPopnamed the route underneath fromRouteSettings.name, so a tab labelled by aNohmoScreenbecame/on the way back — and where the route had no name, nothing was reported and the current screen stayed stuck on the page the user had just left, stamping every later event with it. A visibleNohmoScreennow claims its route, and the observer prefers that name.
Fixed — delivery and resilience #
- A transient 4xx no longer destroys the batch. Every non-5xx response
dropped the events, so one 400 from a bad rolling deploy, a WAF or a captive
portal lost the batch permanently — including the
APP_INSTALLin it, which is never re-sent because the first-open flag is already written. 408, 425 and 429 are retried with 5xx; 400 and rejected credentials are still dropped rather than retried forever. - Retries back off exponentially from 5s to a 5-minute ceiling, with jitter.
A device offline for hours was re-POSTing on every flush interval. An explicit
flush(), including the one on backgrounding, ignores the backoff. - A
NohmoStoragethat throws can no longer kill the SDK. One failed read abortedinitbefore the device id was assigned, and every event afterwards buffered into an uncapped in-memory list that nothing ever drained — silently, permanently, for the life of the process. Reads are individually guarded, the device id is always assigned, and the buffer is capped. - A push token registered while offline is retried on the next start until
the server confirms it, like
linkUseralready was. Uninstall detection depends on that token arriving. - No more silent trimming between two caps. The persisted tail was half the in-memory queue, so events between the two vanished on process death without a word. Both are 1000 now, dropping is reported, and persisting throttles itself when the queue is deep so a large write does not risk an ANR.
install_utmis no longer sent on every event. The install'sutm_*values were stamped onto everySCREEN_VIEW,PRESSandCONVERSIONfor the life of the install, and ingestion never read the field — it is not referenced anywhere in the backend or the dashboard, and is not even stored, since onlydatais persisted as JSON. Nothing is lost: the attribution is still reported as theINSTALL_ATTRIBUTEDevent (which the dashboard does render) and is durable server-side inInstallAttribution, keyed to the device.- Event names, user ids and session ids are clipped to 255 characters.
Ingestion stores all three in 255-char columns with no truncation of its own
(
tracker/models.py:199-201), and one over-long value fails thebulk_createfor the entire batch, which then retries and fails identically — so a single long custom event name from the host app could silently destroy every event batched alongside it. - Device and session ids come from
Random.secure(). - The Play Install Referrer service connection is closed on the timeout path too.
- Android's cold-start deep link is no longer dropped when a cached
FlutterEngineoutlives the activity.
Fixed — autocapture #
- Personal data in a tap label is redacted by default.
textis whatever the control displays, and controls display user data:PRESS "priya@gmail.com"was leaving the device because an app turned autocapture on. Emails, phone numbers, card- and account-length digit runs and currency amounts become placeholders, keeping the wording that makes a report readable. NewNohmoRedactedwidget for what shape-matching cannot catch — names, addresses — andredactText: falseto opt out. - A control behind
IgnorePointerorAbsorbPointerno longer reports a press. It cannot be tapped, so reporting it manufactured dead presses that never happened — the pattern apps use to disable a form mid-submit. - Text fields report a tap, so a form funnel has its "tapped into the email field" step. The value is never read; only the field's own hint or label.
Changed — verified against ingestion #
APP_BACKGROUNDcan now fire more than once persessionId, which follows directly fromsessionTimeout. Checked against the backend before shipping:sessionDurationSecsis never read — nothing in the repo references it — andAPP_BACKGROUNDitself is stored but read by no dashboard, rollup, metric or webhook. Session duration is computed asSUM(TIME_SPENT.data.seconds)per session (tracker/tasks.py:396-419), so the extra events are inert and thesessionDurationSecsfield is carried for parity only.- Session durations will go UP and session counts will go DOWN, both because
the numbers were wrong before. Duration rises because
TIME_SPENTnow fires on backgrounding, and the final screen of every session — previously never measured at all — is now included in the sum the backend takes. Counts fall because one visit interrupted by an OTP or a payment redirect is now one session rather than three. Expect a visible step change in Avg session and Sessions on the dashboard the day this ships. - A session the user starts themselves is now attributed as organic.
Session attribution is cleared when a new session begins (campaign credit for
the install itself lives in
InstallAttribution, which is unaffected). The backend gives a session with noutmblank source fields and no lookback (tracker/tasks.py:424-450), so returning visits count as organic — which is the standard per-session meaning, and what the Traffic page's organic/UTM split already assumes.
Added #
flushInterval: Duration.zerodisables the periodic timer, so the SDK can be initialised inside atestWidgetstest. The liveTimer.periodicotherwise outlives the widget tree and trips Flutter's'!timersPending'assertion beforetearDowncan shut it down.iosPasteboardAttributionoption. The first-launch pasteboard read shows iOS 14+'s "Pasted from …" banner for most users, not only those who tapped a Nohmo link — a comment in the source claimed otherwise, and was wrong.
Fixed — release hygiene #
kNohmoSdkVersionmatches the package again. 0.5.0 shipped stamping0.4.1onto every device record, where it cannot be backfilled, so there was no way to tell whether 0.5.0's fixes had reached anyone.tool/verify.shand a new CI job now assert that the four version declarations agree, that the privacy manifest exists and is wired into both dependency managers, and that the scene-lifecycle registration is present.
0.5.0 #
Fixed #
-
A rejected call is no longer reported as success. Every endpoint except
/trackread the response body without ever checking the HTTP status, so a wrong API key, an unknown project or a device the server had never registered came back 4xx with parseable JSON and the SDK logged success.linkUser()even emittedUSER_LINKEDfor a link that never happened. Failures are now reported withdebugPrint, and rejected credentials produce one clear message namingprojectId,apiKeyandhostrather than a line per call. -
A refused batch of events says so.
/trackchecked only for 5xx, so a 4xx — including a rejected key, and/trackauthenticates by body rather than by header so it can fail on its own — dropped the batch and reported nothing. The drop is still correct (retrying will not fix a refused key) but it is now reported; 5xx still re-queues, unchanged. -
A failed
linkUser()is retried instead of lost. The SDK now 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 reaches the network. A link already confirmed costs no request on later launches. -
A reinstall no longer creates a new anonymous device. The device id lives in
SharedPreferences/NSUserDefaults, which an uninstall wipes, and thestableIdthe backend matches returning devices on was never sent from Flutter — so every reinstall counted a fresh install and detached a logged-in user from their history. The plugin now supplies one: a Keychain UUID on iOS (ThisDeviceOnly, so it never syncs across devices) and a SHA-256 ofANDROID_IDsalted with the package name on Android. Where the platform has none, nostableIdis sent rather than a guessed fingerprint, which would collide across identical handsets and merge two people into one device.This is native code: do a clean build after upgrading.
0.4.1 #
Initial release of the Nohmo Flutter SDK — feature parity with the React Native SDK at the same version.
- Device identity, sessions and batched event delivery with a queue that survives the process being killed.
- Automatic screen tracking via
NohmoNavigatorObserver(SCREEN_VIEW,TIME_SPENT), plusNohmoScreenfor apps that do not route through aNavigator. - Tap autocapture via
NohmoAutocapture(PRESS,LONG_PRESS,RAGE_CLICK) — no build-time codegen required. - App lifecycle events (
APP_INSTALL,APP_OPEN,APP_BACKGROUND). - Install attribution: Play Install Referrer on Android, pasteboard click token on iOS, probabilistic fallback on both.
- Smart Links: direct and deferred deep linking through
Nohmo.deepLinks. - Crash reporting: Flutter framework and uncaught Dart errors as
JS_ERROR; native Android/iOS crashes (including SwiftfatalErrorand signals) asAPP_CRASH, attributed to the session they happened in. - Invite links (
buildInviteLink), conversions, user linking, push-token registration for uninstall detection. - Injectable
httpClientso apps can assert on exactly what the SDK sends. - Swift Package Manager support alongside CocoaPods — both read the same
sources under
ios/nohmo/Sources/nohmo, so CocoaPods users and older Flutter versions are unaffected.
Verified on Flutter stable 3.47.1 (Dart 3.13.1) and beta 3.48.0
(Dart 3.14.0): flutter analyze clean, 29 tests passing, and a real
flutter build apk on both the KGP and Built-in Kotlin Gradle paths
(AGP 9.1 / Kotlin 2.4) with the plugin's classes asserted present in the dex.
iOS sources compile against the iOS 26 SDK for device and simulator, and a real
flutter build ios links NohmoPlugin/NohmoCrash into the app through both
CocoaPods and Swift Package Manager.
Additionally run on a physical Android device (Android 16 / API 36, arm64), confirming install attribution, autocapture, rage clicks, session lifecycle, crash-surviving queue persistence, and native crash capture at runtime.
Fixed during that device testing: Android crash records were written correctly
but never drained. The crash store resolved its directory from a Context that
was only captured when the crash handler was installed, while Dart drains the
previous run's crashes before installing this run's handler — so the read always
returned empty and records accumulated on disk, never reported. readAndClear
now takes the Context directly, removing the ordering dependency.
tool/verify.sh re-runs all of that in one command, and
.github/workflows/flutter.yml runs it against stable and beta on every push
and weekly on a schedule.