telling_kit
Telling for DartNative — crash
reporting, error tracking and analytics, with the
telling_logger API. Code written
against the Flutter package ports by changing one import.
// import 'package:telling_logger/telling_logger.dart';
import 'package:telling_kit/telling_kit.dart';
telling_logger declares flutter: {sdk: flutter} and depends on Flutter
plugins, so it cannot resolve in a DartNative app — DartNative omits
flutter: sdk: from its own pubspec to keep consumer lockfiles Flutter-free,
and its Zero engine calls native code over FFI instead of platform channels.
telling_kit keeps the API and the ingestion contract and replaces the five
Flutter-bound pieces:
telling_logger |
telling_kit |
|---|---|
shared_preferences |
dartnative_shared_preferences |
package_info_plus |
dartnative_system's PackageInfo |
device_info_plus |
libc via dart:ffi — see No native code |
FlutterError.onError, PlatformDispatcher.instance.onError |
Dart zones + an isolate error port |
NavigatorObserver screen tracking |
explicit Telling.instance.screenView(...) |
kDebugMode, kIsWeb |
bool.fromEnvironment('dart.vm.product') |
Setup
dependencies:
dartnative: ^1.0.0
dartnative_shared_preferences: ^1.0.0
dartnative_system: ^1.0.0
telling_kit:
hosted: https://dartpub.dev
version: ^1.0.1
dn pub get
dn pub get resolves the package and the two SDK-managed plugins, then generates
the plugin registrant. Declare dartnative_shared_preferences and
dartnative_system directly in the app, as above: the registry source archive
omits SDK-managed dependencies from the pubspec supplied to Dart pub. There is
nothing to link: no .xcframework, no .aar, no podspec, no gradle module.
Quickstart
import 'package:dartnative/dartnative.dart';
import 'dartnative_plugin_registrant.dart';
import 'package:telling_kit/telling_kit.dart';
void main() {
DartNativePluginRegistrant.registerAll();
// Arm the isolate error listener before anything else can throw.
Telling.instance.armCrashCapture();
Telling.instance.run(() => runApp(const MyApp()));
}
Future<void> startTelling(String apiKey) async {
await Telling.instance.init(apiKey);
Telling.instance.enableCrashReporting();
}
Two rules, both load-bearing:
run(...)must wrap the app body. It installs the zone that catches the async gaps the isolate reports without user context. It is a no-op passthrough when crash reporting was never enabled, so calling it unconditionally is safe. An app that owns its own zone should forward intoTellingCrashReporter.handleZoneErrorrather than nesting a second zone.- Identify the user before tracking a funnel.
setUser()ends the current session, so calling it mid-funnel makes the backend see the steps as belonging to different people.
init is safe to call with no key at all — every Telling call then degrades
to a no-op, which is what makes this package testable in CI and debug builds
without a key.
Keeping the key out of source
Read it from a build-time define rather than committing it:
const apiKey = String.fromEnvironment('TELLING_API_KEY');
What you get
- Analytics —
event,trackFunnel,screenView, and user identity (setUser,clearUser,setUserName,setUserProperty,setUserProperties). - Logs —
logat six levels (trace…fatal), typedgeneral/analytics/crash/performance. - Errors —
captureExceptionfor handled failures, plus automatic capture of uncaught ones.TellingTryCatch(tryRun,tryRunVoid,tryRunSync) wraps calls that should report and carry on. - Version checks —
checkVersion,snoozeUpdate,acceptUpdate. - Transport — gzip past 1 KB, offline persistence through
dartnative_shared_preferences, replay with exponential backoff (5s → 80s). A burst of handled failures costs one request, not one per event. - Rate limiting — 10 events/second, a 5s dedupe window and a 5s crash throttle, so a hot loop cannot flood ingestion.
No native code
Device metadata does not need a plugin either. Both platforms expose what
device_info_plus reports through libc, which DynamicLibrary.process()
resolves directly:
- Apple:
sysctlbyname("hw.machine")→iPhone17,1, the same stringIosInfo.utsname.machinereturns, so dashboards group identically. - Android:
__system_property_get("ro.product.model")andro.product.manufacturer, joined the wayAndroidInfoformats them.
Every lookup degrades to null on failure. Observability must never be the
thing that takes an app down.
Crash capture
This is the one place the port is a genuine rewrite rather than a swap.
enableCrashReporting() arms both paths that work on any Dart runtime:
Isolate.addErrorListenerover aRawReceivePort— sees anything that escaped everytry/catchand every zone handler, including a synchronous throw duringmain(). Strictly stronger than Flutter'sPlatformDispatcher.instance.onError, which only sees errors inside a zone. CallarmCrashCapture()beforeinit()so early throws are buffered rather than lost.runZonedGuarded, installed byTelling.instance.run(...)around the app body — catches the async gaps (Futurewith noerrorHandler, a bareawaitthat throws) that the isolate reports asynchronously with no user context.
One fatal error is commonly reported by both, because an error propagating
out of runZonedGuarded still reaches the isolate. Crashes are fingerprinted
(type + message + top stack frame) and repeats inside a 2-second window are
collapsed, so a crash is one report.
Layout failures (RenderFlex overflowed, constraints, disposed, …) are
classified as warnings, everything else as fatal — the same substring
heuristic the Flutter version used, so an existing dashboard keeps separating
layout noise from real crashes.
Screen tracking
screenTracker and goRouterScreenTracker are gone. DartNative's
NavigatorObserver is an empty class and App.navigatorObserver is never
invoked, so there is no hook to subclass — the Flutter-compatible name exists
so imported code compiles, not because anything calls it.
Telling.instance.screenView('race_detail');
Re-entering the current screen is ignored, and time-on-previous-screen is still logged automatically.
Example
example/ is a runnable DartNative app wired to every feature — events,
funnels, logs at each level, a handled exception, and a real uncaught crash.
cd example
dn pub get
dn run --dart-define=TELLING_API_KEY=your_key
Testing
dn test
debugUseTransport / debugResetTransport swap in a fake transport, so tests
can assert on request volume and payloads without a network.
Relationship to telling_logger
Same API, same wire format, same dashboard. The Flutter package remains the right choice for a Flutter app; this one exists because a DartNative app cannot load it.
Files carried across unchanged — lib/src/models/, lib/src/rate_limiter.dart,
lib/src/utils/stack_trace_parser.dart, lib/src/telling_try_catch.dart —
are kept in the old if (field != null) 'field': field form rather than the
newer ?'field': field marker, deliberately, so they stay diffable against the
Flutter original when re-syncing. Only stack_trace_parser.dart's column
entry must keep the old form: column.toString() is never null, so the
null-aware marker would be a type error there.
License
MIT — see LICENSE.
Version and build checks
From version 1.0.1, checkVersion() automatically sends the installed build number
alongside the version to Telling's project API. Configure optional iOS and Android
minimum builds in your project's version configuration in the Telling app or website.
Version and build minimums are independent; falling below either triggers an update.
Use positive integer builds that increase across releases. Clear a minimum build to
disable it. Missing or nonnumeric installed builds keep the version-only behavior.
final result = await Telling.instance.checkVersion();
if (result.requiresUpdate && !result.isRequired && result.minVersion != null) {
// When the user chooses Later:
await Telling.instance.snoozeUpdate(
days: 3,
minVersion: result.minVersion!,
target: result.snoozeTarget,
);
}
// Before opening the update URL after the user accepts:
// await Telling.instance.acceptUpdate(
// minVersion: result.minVersion, target: result.snoozeTarget,
// );
Results include minBuild, target, and decisionSource. snoozeTarget falls back
to minVersion against older servers. A new build target prompts again even during
an earlier optional-update snooze. Mandatory updates bypass snoozes. Checks fail open
on HTTP, parsing or timeout errors. Old method calls remain source-compatible;
pass the target to snooze build-based updates. No DartNative native binding changes
are needed.