Docs / Apps
Dart
The pure-Dart core, for CLI tools and server-side Dart. One dependency, no platform channels, and every call is a no-op before init rather than a throw.
What you need first
A project's DSN. Create a project, then copy it from that project's settings page: it carries the host and the project id, so it is the only thing this SDK needs to be pointed at.
https://<key>@cyclopes.localhost.co.zw/ingest/<project-id>
The key is write-only: it can send events and nothing else, so shipping it inside a mobile app is fine. Read it from your environment rather than committing it.
Install
Served from this install's own pub repository, not pub.dev.
# pubspec.yaml
dependencies:
cyclopes:
hosted: https://cyclopes.localhost.co.zw/pub
version: ^0.4.0
Writing a Flutter app? Take cyclopes_flutter instead. It depends on this package and reports screens, taps and both error channels for you. Everything on this page still works inside it.
Wire it up
import 'package:cyclopes/cyclopes.dart';
Future<void> main() async {
Cyclopes.init(
dsn: 'https://<key>@cyclopes.localhost.co.zw/ingest/<project-id>',
environment: 'production',
release: 'app@3.1.0',
);
}
To catch all uncaught async errors, run inside a guarded zone:
Future<void> main() => Cyclopes.runGuarded(() async {
Cyclopes.init(dsn: '...');
runApp();
});
Errors
Cyclopes.setTag('device', 'pixel-8');
Cyclopes.setUser({'id': 'u_123'});
try {
risky();
} catch (e, stackTrace) {
Cyclopes.captureException(e, stackTrace: stackTrace);
}
Cyclopes.captureMessage('sync finished', level: 'info');
Errors are sent from a background future and dropped silently if Cyclopes is unreachable, so a monitoring outage never affects your app.
Logs and journeys
Items are buffered and posted as one batch once 30 accumulate, or five seconds after the first, whichever comes first. Every call is safe before init and never throws.
// Leveled logs with optional structured attributes.
Cyclopes.logger.debug('cache warmed');
Cyclopes.logger.info('sync finished', {'items': 42});
Cyclopes.logger.warn('slow frame', {'ms': 480}); // sent as "warning"
Cyclopes.logger.error('payment declined', {'code': 'card_declined'});
// User journey: named events, screen views, identity.
Cyclopes.track('checkout_started', {'cart_total': 59.99});
Cyclopes.screen('Cart', {'items': 3});
Cyclopes.identify({'id': 'u_123', 'email': 'jo@example.com'});
identify() also stores the user as the global identity, so error events fall back to it when no setUser user is set.
screen() records where the user is, and every track() after it says so, which is what lets the dashboard show what people do on a given screen. For an arrival no navigation announces, set Cyclopes.currentScreen yourself.
Sessions
A session is one continuous sitting. The SDK makes an id at init() and rotates it after 30 idle minutes (sessionTimeout; Duration.zero disables), so an app resumed the next morning starts a fresh one.
Cyclopes.newSession(); // rotate explicitly, e.g. on logout
Cyclopes.setSession('abc'); // pin a known id, active from now
Cyclopes.restoreSession(saved.id, saved.lastActivity);
The difference between the last two matters. setSession stamps the id as active now, so a session cold for eight hours would look brand new and never rotate. restoreSession hands back the time it really stopped, and the next event decides.
On a Dart server, scope one per request. Otherwise every user you serve shares one session id and their journeys merge into a single nonsensical path:
Cyclopes.runWithSession(requestId, () {
Cyclopes.track('checkout_started');
});
Nothing leaks across runWithSession: one request's screen is never stamped onto another request's actions.
Flutter, by hand
cyclopes_flutter does all of this for you. This is here for anyone who would rather not add it.
Wire both of Flutter's error channels in main():
// 1. Framework (build/layout/paint) errors.
FlutterError.onError = (details) {
Cyclopes.captureException(details.exception, stackTrace: details.stack, level: 'fatal');
FlutterError.presentError(details);
};
// 2. Uncaught errors from the engine / platform dispatcher.
PlatformDispatcher.instance.onError = (error, stack) {
Cyclopes.captureException(error, stackTrace: stack, level: 'fatal');
return true;
};
Batches ride on a timer, so flush when the app is backgrounded or you lose the tail of every session:
class _CyclopesLifecycleObserver extends WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) Cyclopes.flush();
}
}
// in main(), after runApp:
WidgetsBinding.instance.addObserver(_CyclopesLifecycleObserver());
Breadcrumbs and context
Cyclopes.addBreadcrumb(message: 'app resumed', category: 'lifecycle');
Cyclopes.addBreadcrumb(message: 'tapped Pay', category: 'ui', level: 'info');
Cyclopes.setRequest({'method': 'POST', 'url': 'https://api/pay'});
// runtime + os contexts are attached automatically (guarded for Flutter web)
Every field, breadcrumbs and contexts included, passes through the beforeSend hook before it leaves the client, so you can redact or drop anything.
Then
Naming is the part that is expensive to change once data exists under it, and journeys are what the names are for. Both are the same whatever you wrote the app in:
- Journeys, and the one rule about entry steps people get wrong by hand.
- Naming things, before you have a week of data under a name you regret.
- Coming from Sentry: keep the SDK, change the DSN.
Something here wrong or missing? cyclopes@localhost.co.zw.