flawsnap

In-app bug reporting for Flutter apps. Users shake the phone or tap a floating button, mark up a screenshot of the app, and send. Reports land in the same Snaplog inbox as your website's, tagged ios or android.

Setup

Requires Dart 3.13 / Flutter 3.47 or newer.

  1. In the Snaplog portal, open your project's Security settings and turn on Accept reports from mobile apps. Copy the project key.

  2. Add the package:

    dependencies:
      flawsnap: ^0.0.2
    
  3. Initialise and wrap your app:

    import 'package:flutter/material.dart';
    import 'package:flawsnap/flawsnap.dart';
    
    Future<void> main() async {
      WidgetsFlutterBinding.ensureInitialized();
      await Snaplog.init(apiUrl: 'https://api.example.com/api/v1', projectKey: 'pk_...');
      runApp(Snaplog.wrap(const MyApp()));
    }
    
  4. Optional extras:

    MaterialApp(navigatorObservers: [Snaplog.navigatorObserver])  // report the current screen
    final client = Snaplog.httpClient();                           // record network calls
    Snaplog.identify(name: user.name, email: user.email);          // prefill the reporter
    Snaplog.log('Payment failed', level: 'error');                 // custom log lines
    Snaplog.setMetadata({'plan': 'pro'});                          // extra details
    Snaplog.show();                                                // open the reporter yourself
    

    Choose triggers with Snaplog.init(..., triggers: {SnaplogTrigger.button}) (default: shake and button). Shake needs a physical device.

    Admins can hide the floating button from the project's settings in the portal, or set its position (left/right) and colour; the label set there is used as the button's accessibility label. Shake and Snaplog.show() keep working when the button is hidden.

Team sign-in

When the project allows it (portal: project settings, "Let team members sign in from the widget and app"), the report screen shows Team member? Sign in. The SDK shows a code like ABCD-EFGH and opens Snaplog's approval page in the browser. The member signs in there (or on another device, with the code) and presses Allow. From then on their reports go in under their Snaplog account ("Reporting as Jane · Sign out"), without the name and email fields. Everyone else reports anonymously as before.

  • Snaplog.signIn() / Snaplog.signOut() do the same from your own UI.
  • The app gets a reporter token, not a Snaplog session. It can only send reports to this project and lasts 30 days. If the API rejects it (expired, revoked, or the setting turned off), the name and email fields come back.
  • The token is kept in shared_preferences. To keep it encrypted at rest, implement ReporterTokenStore with flutter_secure_storage and pass it as Snaplog.init(..., tokenStore: MyStore()).
  • On Android, auto backup copies shared_preferences to the user's cloud backup by default. To keep the token out of it, exclude FlutterSharedPreferences.xml in your backup rules (android:fullBackupContent / android:dataExtractionRules), set android:allowBackup="false", or use the secure-storage option above.

Insights (user flow, API timing and errors)

Snaplog Insights draws your app's user flow: which screens people open, in what order, where they go back or leave, how long they stay, and which API calls each screen makes (and which are slow). It also groups Dart errors into issues. It's off unless both of these are on:

  1. The project's Insights switch in the portal (Insights → Setup).

  2. insights: true in your app, plus the navigator observer:

    await Snaplog.init(
      apiUrl: 'https://api.example.com/api/v1',
      projectKey: 'pk_...',
      insights: true,
    );
    // ...
    MaterialApp(navigatorObservers: [Snaplog.navigatorObserver], ...)
    

Screen names

Screens are named after their route's RouteSettings.name, so give every page route a name; unnamed routes show up as (unnamed). Only full-screen page routes count; dialogs and bottom sheets don't.

  • Named routes / Navigator.pushNamed: names come from your routes: table or onGenerateRoute. For MaterialPageRoute, pass settings: const RouteSettings(name: 'Cart').

  • GoRouter: add the observer and give each route a name:

    GoRouter(
      observers: [Snaplog.navigatorObserver],
      routes: [GoRoute(name: 'Cart', path: '/cart', builder: ...)],
    )
    

    If your routes use pageBuilder, pass the name on the page too: MaterialPage(name: state.name, child: ...). A ShellRoute's nested navigator needs its own observer, and one observer can only watch one navigator, so inside shells call Snaplog.screen('Orders') from the screen instead.

  • auto_route: pass the observer to the router config; screens are named after your route classes (CartRoute):

    MaterialApp.router(
      routerConfig: _appRouter.config(
        navigatorObservers: () => [Snaplog.navigatorObserver],
      ),
    )
    
  • Anything else (tabs, page views, custom navigation): Snaplog.screen('Settings') names the current screen from your code.

What's sent:

  • Screens: each screen view with how the user got there (opened, pushed, went back, replaced), the previous screen, and time on screen while the app is in the foreground. A visit ends when the app is closed or after 30 minutes in the background.
  • API calls made through Snaplog.httpClient() (method, URL without query string or fragment, status, duration), each tied to the screen it was made on. Use it as your package:http client, or wrap your own: Snaplog.httpClient(myClient). Calls through other clients (e.g. Dio) aren't seen yet.
  • Errors caught by FlutterError.onError and PlatformDispatcher.onError (type, message up to 500 characters, top 30 stack frames). Native iOS/Android crashes aren't included. Stacks are as compiled; obfuscated builds show obfuscated names.
  • With each batch: your app version, ios or android, and a random visit id. The visit id isn't tied to the device or user, and every launch starts a new one; it's saved only alongside events waiting to be sent, and deleted with them once they're sent.
  • The user id, only when the project recognises returning users (below). Unlike the visit id, it stays on the device across launches.

Never sent: request or response bodies, headers, query strings, your app's account ids, or anything given to Snaplog.identify(). URLs inside error messages and stacks lose their query strings, and email addresses are masked, before anything is saved or sent. Screen names are masked the same way (ids, UUIDs, emails). Events go in batches (every 15 s, at 20 events, and when the app goes to the background). Unsent events (at most 200, about 256 KB) wait in shared_preferences and go on the next launch; when Insights is off or consent is withdrawn, that buffer is cleared.

Returning users and actions

When the project also turns on Recognise returning visitors (Insights → Setup), the SDK keeps a random id (u_ + 26 characters) in shared_preferences (snaplog.uid.<project key>) and sends it with Insights events, so one person's sessions can be followed as a journey. It isn't your account id and isn't linked to a name or email: Snaplog.identify() stays for bug reports only and never reaches Insights. A reinstall gives a new id, and turning the setting off deletes it at the next launch.

  • Snaplog.track('signup_completed') records an action on the current screen. Names are lowercase letters, digits and _ . : - (1 to 64); no properties. Invalid names are ignored, including names with a part that looks like an id (6+ digits in a row, a long hex id, a UUID). At most 200 actions are sent per launch (20 of the same name).
  • Snaplog.resetUser() deletes the id (say, on sign-out); the rest of this launch is anonymous and a new id is made on the next launch.
  • Snaplog.consent(false) deletes the id. consent(true) after a withdrawal starts with a fresh one; calling consent(true) again while allowed keeps the current id.
  • Consent first (as in the EU, where storing this id needs consent): Snaplog.init(..., insights: true, consentPending: true). Insights then sends nothing and keeps no id until your consent flow calls Snaplog.consent(true); a returning user keeps their id.

Snaplog.consent(false) stops Insights and deletes everything not sent yet, in memory and on the device. Snaplog.consent(true) turns it back on with a new visit; nothing captured in between is sent. It's safe to call before Snaplog.init, and the choice isn't remembered, so call it from your consent flow on every launch (or only pass insights: true once the user agreed).

App store privacy labels

If you turn Insights on, declare this data. Snaplog collects it on your behalf, it isn't linked to the user's identity (the user id is a random id, not your account id), and it isn't used for tracking.

  • App Store (App Privacy):
    • Diagnostics → Crash Data: app functionality, not linked, no tracking.
    • Diagnostics → Performance Data: app functionality / analytics, not linked, no tracking.
    • Usage Data → Product Interaction (screens opened and time on them): analytics / app functionality, not linked, no tracking.
    • With Recognise returning visitors on: Identifiers → User ID (the random Snaplog id): analytics, not linked to the user's identity, not used for tracking.
  • Google Play (Data safety):
    • App info and performance → Crash logs and Diagnostics: collected, not shared, for app functionality / analytics, processed ephemerally: no.
    • App activity → App interactions (screens opened and time on them): collected, not shared, for analytics.
    • With Recognise returning visitors on: Device or other IDs (the random Snaplog id): collected, not shared, for analytics.
    • Data is encrypted in transit (HTTPS). Users can opt out if you wire Snaplog.consent(false) to a setting or consent prompt.

What a report includes

  • Screenshot of the app with the user's marks (box, draw, five colours).
  • Title, description, priority, and "Who should look?" from your project's categories.
  • Device model, OS and version, app name, version and build, screen size, pixel ratio, locale, light/dark mode.
  • The current route (with navigatorObserver).
  • The last 100 log lines: Flutter errors, uncaught exceptions, debugPrint output and Snaplog.log calls.
  • The last 100 requests made with Snaplog.httpClient() (method, URL, status, timing; bodies are never recorded).

If the key is wrong or mobile reporting is off for the project, init logs why and the SDK stays hidden; your app is unaffected.

Local development

The example app talks to a local API:

cd example
cp dart_defines.example.env dart_defines.env   # then put in your project key
flutter run --dart-define-from-file=dart_defines.env

The iOS example allows plain HTTP to local addresses (NSAllowsLocalNetworking) for development. Production apps should use an https API URL. The Android emulator reaches your machine at http://10.0.2.2:8080, and needs a cleartext network-security exception for HTTP.

Tests

flutter test

Libraries

flawsnap
Flawsnap: in-app bug reporting for Flutter, powered by Snaplog.