cyclopes_
sign in sign up

Docs / Apps

Flutter

Two lines to install and wire. Screens, taps, both error channels and a flush before the OS suspends you, without tagging anything.

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

The package is served by this install, not by pub.dev, so it is a hosted dependency.

# pubspec.yaml
dependencies:
  cyclopes_flutter:
    hosted: https://cyclopes.localhost.co.zw/pub
    version: ^0.4.0

0.4.0 is what this server is serving right now. The generator, cyclopes_flutter_gen, is a dev dependency and only needed if you tag (below).

Wire it up

import 'package:cyclopes_flutter/cyclopes_flutter.dart';

Future<void> main() async {
  await CyclopesFlutter.init(
    dsn: 'https://<key>@cyclopes.localhost.co.zw/ingest/<project-id>',
    environment: 'production',
    release: 'app@3.1.0',
  );
  runApp(CyclopesFlutter.wrap(const MyApp()));
}

and on your MaterialApp:

MaterialApp(
  navigatorObservers: CyclopesFlutter.navigatorObservers,
  home: const HomeScreen(),
)

That is the whole integration. Everything below is about naming things, and about the screens a NavigatorObserver cannot see.

What you get without tagging anything

ScreensNamed routes become screen views, and returning to a screen counts as reaching it again.
ErrorsFlutterError.onError and PlatformDispatcher.onError both wired, chaining whatever was already there, so the debug red screen still works.
Lifecycleflush() on the way to the background, so the tail of a session is not lost with the process.
TapsA breadcrumb per tap, so a crash report shows what the user was doing.
Where it happenedEvery action carries the screen the user was on, so the map can show what people do on a screen and not only the order they saw them in.

Each is individually switchable through CyclopesFlutterOptions.

Two things are deliberately not captured. Dialogs and bottom sheets are not screens: they sit on top of one rather than replacing it, and counting them turns every confirmation prompt into a node on the flow map. Neither is the route called /, which is what MaterialApp(home:) pushes and which names nothing.

Actions taken inside a sheet are attributed to the screen underneath. If a sheet is substantial enough to deserve its own name, wrap it in a CyclopesScreenScope, or set Cyclopes.currentScreen while it is up for the attribution without a node on the map.

Screens that are not routes

Most Flutter apps navigate by more than pushing routes: a tab index, a step in a wizard, a branch in build. A NavigatorObserver cannot see any of it. Wrap the subtree instead.

CyclopesScreenScope(
  name: 'Monitor.${_tab.name}',
  child: _body(),
)

Put it where the screen is built, not in the callback that changed the tab. Callbacks miss the paths you forgot about: a start tab from a --dart-define, a redirect after connecting, a reset on sign-out. Everything that changes what is on screen goes through the build, which is why wrapping catches cases a callback silently drops.

Tagging

Tags are declarations. They do nothing at runtime on their own: the generator turns them into the wiring, and into a manifest of every screen and action your app can emit, so the dashboard knows your screen names before anybody opens the app.

@CyclopesScreen('Cart')
class _CartPageState extends State<CartPage> with _$CartPageScreen {

  @CyclopesAction('pay_tapped', captureFields: {'plan': '_plan'})
  void _onPay({required double amount}) { ... }
}

Omit the name and it is derived: _CartPageState becomes Cart, _onPayTapped becomes pay_tapped. The derivation follows the naming rules, so the documented convention is what you get for free.

For a one-off, skip the generator entirely:

onPressed: cyclopesAction('pay_tapped', _onPay, props: {'plan': _plan}),

CyclopesTap(
  action: 'issue_opened',
  props: {'level': issue.level},
  child: IssueRow(issue),
)

CyclopesTap does not detect the tap itself. A wrapper with its own GestureDetector would enter the gesture arena alongside the button it wraps and lose, silently. It leaves a marker instead, and the global tap listener names the action after whichever marker the tap landed inside: works for a button, a row built in a loop, and an InkWell three widgets deep, identically.

Privacy

Tap labels default to CyclopesTapLabelSource.keysOnly: a label is reported only when it comes from a ValueKey<String> or an explicit Semantics(identifier:), both of which a developer typed on purpose.

CyclopesTapLabelSource.text reads the nearest Text instead. It is more useful, and it is how an email address ends up in a breadcrumb attached to your next error event. Opt into it knowingly.

The same posture applies to @CyclopesAction: fields are captured one at a time by name, never wholesale. A State holds controllers and API clients, and the batcher stringifies whatever it cannot serialise, so a blanket capture would quietly post the contents of a TextEditingController.

Everything passes through options.redactProperties before it leaves the device.

Sessions across a cold start

A session means one continuous sitting. On a phone that routinely spans a process death: the user is interrupted, the OS reclaims the app, they come back a minute later. Give it somewhere to write the session id and that stays one session.

final dir = await getApplicationSupportDirectory();
await CyclopesFlutter.init(
  dsn: '...',
  options: CyclopesFlutterOptions(
    sessionStore: CyclopesFileSessionStore('${dir.path}/cyclopes_session.json'),
  ),
);

The path is yours to supply rather than discovered here, so this package needs no native plugin. Without a store nothing is written, which is the default: a monitoring package should not start writing to a user's disk because it was added.

Options

CyclopesFlutterOptions(
  captureScreens: true,
  captureLifecycle: true,
  captureErrors: true,
  captureTaps: true,
  tapLabelSource: CyclopesTapLabelSource.keysOnly,
  screenDedupeWindow: Duration(milliseconds: 700),
  tapCoalesceWindow: Duration(milliseconds: 300),
  sessionTimeout: Duration(minutes: 30),
  sessionStore: CyclopesNoopSessionStore(),
  markAutoCaptured: true,
  routeFilter: CyclopesFlutterOptions.defaultRouteFilter,
  redactProperties: null,
)

Anything this package captured on its own carries cy_auto: true in its properties. It rides inside properties because the ingest schema ignores unknown top-level fields, and being a property is also what makes it filterable: a journey step can require a hand-written event and refuse an inferred one.