# Magic Sentry documentation > Magic Sentry wires Sentry error and performance monitoring into a Magic app: boot, HTTP failures, the signed-in user and breadcrumbs. # Installation Add `magic_sentry` to a Magic app, run the installer, wrap `main()` in `MagicSentry.run` and set a DSN. - [Install](#install) - [Requirements](#requirements) - [Wire main](#wire-main) - [Register the Provider](#register-the-provider) - [Configuration](#configuration) - [Release Health](#release-health) - [Verify](#verify) - [Related Documentation](#related-documentation) ## Install ```bash flutter pub add magic_sentry dart run magic:dowel plugin:install magic_sentry ``` The installer reads the package's `install.yaml`, publishes `lib/config/sentry.dart` (your own copy, free to edit) and injects `SentryServiceProvider` into the `providers` list of `lib/config/app.dart`. It cannot rewrite `main()`, so that step is yours. There is no `magic.config_factory` entry: Sentry starts before `Magic.init`, when `Config` does not exist yet. ## Requirements | Requirement | Value | |:------------|:------| | Dart | `>=3.13.0` | | Flutter | `>=3.47.0` | | Magic | `^1.0.0`, installed and bootstrapped (`lib/config/app.dart` exists). The token scrubber reads `MagicRouter.allRoutes` | | Sentry SDK | `sentry_flutter` and `sentry_dio` `^10.4.0`, pulled in by the package. Version 10.4 needs Android `minSdk` 24, iOS 15 and macOS 12, and uses Swift Package Manager instead of CocoaPods | An app on `sentry_flutter` 9 stays on magic_sentry 0.0.6. Version 10 removed `enableLogs`, `enableMetrics`, `profilesSampleRate` and `enableStandaloneAppStartTracing` from the init options, and gave `beforeSendLog` and `beforeSendMetric` a second `Hint` parameter; drop or update those in your own `configure` callback. ## Wire main `MagicSentry.run` is the whole body of `main()`. It opens one zone, creates Sentry's widgets binding, loads `.env`, starts Sentry and hands control to your boot function. ```dart import 'package:magic/magic.dart'; import 'package:magic_sentry/magic_sentry.dart'; import 'package:material_ui/material_ui.dart'; import 'config/app.dart'; import 'config/sentry.dart'; // published by plugin:install void main() { MagicSentry.run(configure: configureSentry, appRunner: _boot); } Future _boot() async { // Tags a build error that replaced the interface; call it before the first widget builds. MagicSentry.installErrorWidgetBreadcrumb(); await Magic.init( configFactories: [ () => appConfig, // Keep every other config factory your main() already passes. ], ); runApp(MagicApplication(title: 'My App')); } ``` `configureSentry` reads `.env` directly. Write your own `configure` callback when the standard keys are not enough, and set your own `beforeSend`, `beforeBreadcrumb` and similar hooks inside it, never after `SentryFlutter.init`. > [!IMPORTANT] > Do not call `WidgetsFlutterBinding.ensureInitialized()` before `run`: an existing binding is kept, and Sentry's frame tracking then disables itself. Do not put anything else in `main()` either, or the app and its binding can end up in different zones. ## Register the Provider The installer adds the provider with no type argument and no callbacks, which is a working default: the network interceptor and the navigator observer wire up and user reporting is skipped. Edit the entry the installer added to the `providers` list of `lib/config/app.dart` to report your own user (`User` is your app's model): ```dart import 'package:magic_sentry/magic_sentry.dart'; import '../app/models/user.dart'; final List providers = [ // ...your other providers (app) => SentryServiceProvider( app, userId: (user) => user.id?.toString() ?? '', userEmail: (user) => user.email, ), ]; ``` `Model.id` is `dynamic` and is often an `int`, so return a `String`: an int id throws when the user context installs, and a signed-in session restored at boot would take `Magic.init` down with it. ## Configuration `lib/config/sentry.dart` reads these `.env` keys: | Key | Default | Purpose | |:----|:--------|:--------| | `SENTRY_DSN` | `''` (off) | The project DSN. Empty leaves the SDK inert and `appRunner` still runs. | | `SENTRY_ENVIRONMENT` | `local` | Tags every event: `production`, `staging`. | | `SENTRY_RELEASE` | unset | The release attached to every event. Required on web, which has no manifest to derive one from; write it into the `.env` you bundle at build time (`Env` does not read `--dart-define`). | | `SENTRY_TRACES_SAMPLE_RATE` | `0.2` | Fraction of traces sampled. Client spans continue into the API through `sentry_dio`'s `sentry-trace` header, so a higher rate raises server spans too. | Two things are never read from `.env`, on purpose. `sendDefaultPii` stays `false`: turning it on sends the user's IP address and request bodies with every event, so an app that needs it sets it in its own `configure`. `configureSentry` never enables session replay. ## Release Health `configureSentry` turns on `enableAutoSessionTracking`. On web a session is recorded only when `SentryNavigatorObserver` sees a named route change, because it reads `RouteSettings.name`. The provider registers the observer, so give your routes a `name` or release health stays empty on web while error reporting keeps working. On Android, iOS and macOS the Sentry SDK hands the option to the native SDK. ## Verify Set `SENTRY_DSN` in `.env`, run the app and file one event: ```dart import 'package:sentry_flutter/sentry_flutter.dart'; Future verify() async { assert(Sentry.isEnabled, 'SENTRY_DSN is empty, so the SDK is off'); await Sentry.captureMessage('magic_sentry installed'); } ``` The message shows up in the Sentry project within a few seconds. With an empty DSN `Sentry.isEnabled` is `false` and every integration in this package does nothing. ## Related Documentation - [Boot and the Provider](https://anilcancakir.com/magic/docs/sentry/basics/boot-and-provider.md): what `run` and `boot()` wire, in order. - [HTTP Reporting](https://anilcancakir.com/magic/docs/sentry/basics/http-reporting.md): which failures become issues. - [Token Scrubbing](https://anilcancakir.com/magic/docs/sentry/guides/token-scrubbing.md): what is masked before anything is sent. --- # Boot and the Provider `MagicSentry.run` starts Sentry before `Magic.init`; `SentryServiceProvider` wires the rest once Magic is up. - [Quick Reference](#quick-reference) - [MagicSentry.run](#magicsentry-run) - [Error Widget Breadcrumb](#error-widget-breadcrumb) - [SentryServiceProvider](#sentryserviceprovider) - [Inert Without a DSN](#inert-without-a-dsn) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Quick Reference ```dart import 'package:magic/magic.dart'; import 'package:magic_sentry/magic_sentry.dart'; import 'package:material_ui/material_ui.dart'; import 'config/sentry.dart'; // published by plugin:install void main() { MagicSentry.run(configure: configureSentry, appRunner: _boot); } Future _boot() async { MagicSentry.installErrorWidgetBreadcrumb(); await Magic.init( configFactories: [ // Every config factory your main() already passes. ], ); runApp(MagicApplication(title: 'My App')); } ``` | Member | What it does | |:-------|:-------------| | `MagicSentry.run` | Opens one zone, creates `SentryWidgetsFlutterBinding`, loads `.env`, calls `SentryFlutter.init`, then your `appRunner` | | `MagicSentry.installErrorWidgetBreadcrumb` | Adds a `fatal` breadcrumb when a build error replaces the interface | | `SentryServiceProvider` | Wires the network interceptor, the user context, event breadcrumbs and the navigator observer in `boot()` | ## MagicSentry.run `run` takes two callbacks. `configure` receives the `SentryFlutterOptions` to fill in (DSN, environment, sample rates); `appRunner` is everything the app does afterwards, typically `Magic.init` followed by `runApp`. The order inside `run` is fixed: the zone opens, `SentryWidgetsFlutterBinding.ensureInitialized()` runs, `Env.load()` runs, then `SentryFlutter.init`. `SentryTokenScrubber` is installed after `configure` returns, so it runs first in every hook and hands its masked value to whichever hook `configure` set. A zone-level error goes to `Sentry.captureException` and is still dumped to the console. One zone for everything is the point. On Flutter web `SentryFlutter.init` opens its own `runZonedGuarded` only when called from the root zone (flutter/flutter#100277), so entering a zone first keeps the app and its binding in the same zone and avoids Flutter's "Zone mismatch" error. Sentry has to start before `Magic.init` so that a failure during boot is reported too. At that point `Config` is not loaded, which is why `configure` reads `.env` through `Env` and never through `Config.get`. ## Error Widget Breadcrumb Flutter already reports the exception behind a broken build through `FlutterError.onError`. What `installErrorWidgetBreadcrumb` adds is a `fatal` breadcrumb (`category: ui.error_widget`) that tells "an exception got logged" apart from "an exception replaced the interface with the grey error box", which look identical in the issue list otherwise. It returns the default error widget unchanged, because a replacement screen needs colours, fonts and a theme, each of which can throw in the exact situation this runs in. Call it once, early in `appRunner`. ## SentryServiceProvider Register it in the `providers` list of `lib/config/app.dart`. `register()` binds nothing. `boot()` returns at once unless `Sentry.isEnabled`, then wires, in this order: 1. The network driver: `sentry_dio`'s `addSentry(captureFailedRequests: false)` on a `DioNetworkDriver`, then `SentryNetworkInterceptor` into Magic's interceptor chain. 2. `SentryUserContext.install()`, when `userId` was given. 3. The event breadcrumb listener, through `Event.listenAny`. A second boot in the same isolate removes the previous listener first. 4. `SentryNavigatorObserver` on `MagicRouter`. | Parameter | Type | Effect | |:----------|:-----|:-------| | `userId` | `String Function(T user)?` | Turns user reporting on. Leave it out to skip user reporting; everything else still wires up | | `userEmail` | `String? Function(T user)?` | Optional. A null or empty email reports the id alone | | `userExtras` | `Map Function(T user)?` | Scope tags applied while a user is reported | `T extends Model` is your own `Authenticatable` model. The observer is registered in `boot()` because `MagicRouter` refuses one after it has built its `routerConfig`, which happens on the first frame; without it every event is filed against a route the app never names, since Magic drives go_router and the SDK cannot see through it. The network driver is resolved from the container, so a host that never registered one (a bare widget test) would throw there. That one failure is caught narrowly and always reported: `Log.warning` when a `log` binding exists, `debugPrint` otherwise. It never fails the host's boot. ## Inert Without a DSN An empty `SENTRY_DSN` makes `Sentry.isEnabled` answer `false`. `boot()` then returns before wiring anything, `appRunner` still runs, and every other member of the package does nothing. That is every local run and the whole test suite unless a test calls `Sentry.init` itself. `sentryDsn` is a `String` and never null on purpose: `Sentry.init` throws on a null DSN, which would stop the app booting on every machine that has not set one. ## Gotchas | Mistake | Fix | |:--------|:----| | Calling `Magic.init` before `MagicSentry.run` | `run` is the whole body of `main()`; `Magic.init` goes inside `appRunner` | | Reading the Sentry config through `Config.get` | `Config` is not loaded when `configure` runs; read `.env` through `Env`, as the published file does | | Importing `configureSentry` from the package | The barrel exports no config names; import your own `config/sentry.dart` | | Calling `WidgetsFlutterBinding.ensureInitialized()` first | Remove it; the existing binding is kept and frame tracking disables itself | | Registering the navigator observer in `main()` | The provider does it in `boot()`, before the router builds its config | | Assigning `beforeSend` after `SentryFlutter.init` | The assignment replaces the scrubber; set app hooks inside `configure` | ## Related Documentation - [Installation](https://anilcancakir.com/magic/docs/sentry/getting-started/installation.md): the installer and the `.env` keys. - [HTTP Reporting](https://anilcancakir.com/magic/docs/sentry/basics/http-reporting.md): the interceptor the provider adds. - [User Context](https://anilcancakir.com/magic/docs/sentry/basics/user-context.md): `userId`, `userEmail` and `userExtras` in depth. - [Token Scrubbing](https://anilcancakir.com/magic/docs/sentry/guides/token-scrubbing.md): the hooks `run` installs. --- # HTTP Reporting `SentryNetworkInterceptor` turns the failures Magic's `Http` facade returns as values into Sentry events and breadcrumbs. - [Quick Reference](#quick-reference) - [Why It Exists](#why-it-exists) - [Events and Breadcrumbs](#events-and-breadcrumbs) - [Grouping](#grouping) - [Repeats](#repeats) - [Double Reporting](#double-reporting) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Quick Reference The provider registers the interceptor for you. Nothing to call: | Status | What Sentry gets | |:-------|:-----------------| | `0` (no response: timeout, refused connection, CORS rejection on web) | An event | | `500` and above | An event | | Anything else, such as `401`, `404`, `422`, `429` | A `warning` breadcrumb on whatever fails next | ## Why It Exists Magic's `Http` facade never throws. Every call returns a `MagicResponse` and the caller reads `response.successful`, so a failure is a value, not an exception. A global error handler sees none of them, and an app's own habit is usually to log the failure and return. Without this interceptor Sentry would report almost nothing about the surface that breaks most. The interceptor only reports. It returns the `MagicError` it was given, so observability can never change the outcome of a request. ## Events and Breadcrumbs A 4xx is usually the product working: `401` is an expired session, `422` a form filled in wrong, `404` a record someone deleted. Filing those as issues turns the inbox into one nobody opens, so they become breadcrumbs that give context to the next real failure. An event carries: | Field | Value | |:------|:------| | Level and logger | `error`, `http` | | Message | `METHOD endpoint failed with STATUS` | | Fingerprint | `['http', method, endpoint, 'STATUS']` | | Context `http_failure` | `method`, `endpoint`, `status_code`, and `transport_message` when there is one | There is no request body and no header in it. An app that turns `sendDefaultPii` on opts into that risk itself. ## Grouping The fingerprint carries a normalised endpoint, because a raw path holds a record id and would open one issue per record. `SentryNetworkInterceptor.normalizeEndpoint` drops the query and turns a numeric or UUID segment into `{id}`, so `/monitors/42/checks` and `/monitors/43/checks` are one issue. The status comes last in the fingerprint, which keeps one endpoint's `500` and `502` apart: they usually have different causes. The endpoint and fingerprint of a failed HTTP call go through `SentryTokenScrubber.scrubApiUrl` (a breadcrumb or span URL keeps its ids and query and is only masked), which masks query values and route-bound tokens first, then runs `normalizeEndpoint`, and last masks any other opaque segment, so a failed invitation call groups under `/api/v1/invitations/{token}/accept` instead of carrying the token, provided the token is 32 or more `[A-Za-z0-9_-]` characters. A route rule such as `/invitations/:token/accept` matches a path that starts at the route, not one behind an `/api` prefix, so a shorter token in an API path is not masked. ## Repeats One event per distinct `METHOD endpoint status` per session; the next occurrence becomes an `error` breadcrumb marked `(repeat)`. Without that, a client polling a dead endpoint is a flood source: a one-hour outage across a few hundred open tabs is tens of thousands of identical events, and most Sentry plans bill by event count. The repeat breadcrumbs still show the retry pattern to whoever opens the issue. The set is `SentryNetworkInterceptor.reportedFailures`. It is process-wide and not time-windowed: a web session is a tab, so it ends with the page, and a returning user reports the failure again. ## Double Reporting `sentry_dio` can capture failed requests on its own. The provider turns that off (`captureFailedRequests: false`) for two reasons: its events are raised inside the SDK adapter, so their stack trace points at the SDK instead of your call site, and they cannot carry the endpoint fingerprint above. Leaving it on would also report every failure twice. The provider still adds `sentry_dio`'s `addSentry()`, which contributes an HTTP breadcrumb and a span per request. ## Gotchas | Mistake | Fix | |:--------|:----| | Turning `captureFailedRequests` back on in your own Dio setup | Leave it off; `SentryNetworkInterceptor` is the one layer that decides event or breadcrumb | | Expecting a `404` or `422` to open an issue | They are breadcrumbs by design; look at the breadcrumbs of the next event | | Expecting every failed call to arrive as an event | Only the first of a repeated failure per session does; the rest are `(repeat)` breadcrumbs | | Calling `normalizeEndpoint` to make a URL safe to send | It collapses ids only and a token in the path survives it; use `SentryTokenScrubber.scrubApiUrl` | ## Related Documentation - [Boot and the Provider](https://anilcancakir.com/magic/docs/sentry/basics/boot-and-provider.md): where the interceptor is wired. - [Token Scrubbing](https://anilcancakir.com/magic/docs/sentry/guides/token-scrubbing.md): the masking `scrubApiUrl` applies. - [Testing](https://anilcancakir.com/magic/docs/sentry/guides/testing.md): clearing `reportedFailures` between tests. --- # User Context `SentryUserContext` keeps Sentry's scope user in step with `Auth.stateNotifier`, so every event says who hit it. - [Quick Reference](#quick-reference) - [Why the Auth State Drives It](#why-the-auth-state-drives-it) - [Extras](#extras) - [Using It Without the Provider](#using-it-without-the-provider) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Quick Reference Pass the extractors to the provider and user reporting is on: ```dart import 'package:magic_sentry/magic_sentry.dart'; import '../app/models/user.dart'; // The entry in the providers list of lib/config/app.dart; User is your app's model. final List providers = [ (app) => SentryServiceProvider( app, userId: (user) => user.id?.toString() ?? '', userEmail: (user) => user.email, userExtras: (user) => {'plan': user.plan}, ), ]; ``` | Callback | Runs | Result | |:---------|:-----|:-------| | `userId` | On every auth state change with a signed-in user | The Sentry user id. An empty string counts as nobody | | `userEmail` | Only for a user with a non-empty id | The email, or none when it is null or empty | | `userExtras` | Only while a user is reported | Scope tags; each call replaces the keys of the previous one | `T` is the host app's own `Authenticatable` model. The package has no user model, team or tag of its own, and stays that way: app-specific data arrives through these callbacks. ## Why the Auth State Drives It `install()` subscribes to `Auth.stateNotifier`, which fires on login, logout, a session restored at boot and, in an app with teams, a team switch. Doing it there rather than at each call site keeps a future one correct for free, and it guarantees the logout case: a scope that keeps the previous user would file the next visitor's errors under the person who signed out, which is worse than no user at all. So `apply()` sets the user to `null` explicitly on sign-out. `install()` also applies the current state at once. `Auth.restore()` usually runs during boot, so a user is often signed in by the time this installs, and waiting for the next change would leave the whole first session anonymous. ## Extras `userExtras` returns a `Map` of scope tags. Its keys replace whatever the previous call set, and a sign-out clears them, so a team switch carries the new value without leaking the old one. Read straight off the model; the callbacks run on every auth change, so no network call and no side effect. ## Using It Without the Provider Construct the context yourself only when you wire Sentry outside `SentryServiceProvider`: ```dart import 'package:magic_sentry/magic_sentry.dart'; import '../app/models/user.dart'; void followAuth() { SentryUserContext( id: (user) => user.id?.toString() ?? '', email: (user) => user.email, extras: (user) => {'plan': user.plan}, ).install(); } ``` Calling `install()` twice on the same instance adds no second listener. The context compares the notifier by identity, so it also resubscribes when `Auth.stateNotifier` itself changes, as it does after a test fakes a new guard. ## Gotchas | Mistake | Fix | |:--------|:----| | Returning `user.id` as it is | `Model.id` is `dynamic` and often an `int`, which throws when the context installs; return `user.id?.toString() ?? ''` | | Passing `userEmail` without `userId` | Nothing is reported; `userId` is what turns user reporting on | | Doing a request or a lookup inside `userId` or `userEmail` | They run on every auth change; read straight off the model | | An `id` that is empty for a signed-out visitor | That is fine: an empty id means nobody, so no stale user is kept | | Adding an app concept such as a team to this package | Return it from `userExtras` instead | ## Related Documentation - [Boot and the Provider](https://anilcancakir.com/magic/docs/sentry/basics/boot-and-provider.md): where `install()` is called. - [Testing](https://anilcancakir.com/magic/docs/sentry/guides/testing.md): reading the scope user in a test. --- # Event Breadcrumbs Any Magic event that implements `ReportsBreadcrumb` becomes a Sentry breadcrumb, with no extra wiring. - [Quick Reference](#quick-reference) - [How It Works](#how-it-works) - [What Goes in the Data](#what-goes-in-the-data) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Quick Reference Make the event opt in; the provider's wildcard listener does the rest: ```dart import 'package:magic/magic.dart'; class InvoicePaid extends MagicEvent implements ReportsBreadcrumb { InvoicePaid(this.invoiceId); final String invoiceId; @override String get breadcrumbCategory => 'billing'; @override String get breadcrumbMessage => 'Invoice paid'; @override Map get breadcrumbData => {'invoice_id': invoiceId}; } ``` Dispatching `InvoicePaid` adds an `info` breadcrumb with that category, message and data to the next event Sentry sends. ## How It Works `SentryServiceProvider.boot()` registers one listener through `Event.listenAny`, which runs on every dispatch. For each event it asks `EventBreadcrumbs.breadcrumbFor`, which answers `null` unless the event implements `ReportsBreadcrumb`, and adds what it returns with `Sentry.addBreadcrumb`. This package does not know which events exist; the event opts in. A second `boot()` in the same isolate removes the previous listener before adding one, so a test suite that boots the provider again does not register it twice. `magic_deeplink`'s `DeeplinkOpened` and `DeeplinkNavigating` already implement the contract: `DeeplinkOpened` carries the matched route pattern, the link's source and a names-tenant flag, and `DeeplinkNavigating` carries the route pattern only, never the path or the query. ## What Goes in the Data `breadcrumbData` is a whitelist the event author curates. `breadcrumbFor` copies it as it is and never adds or drops a key. The breadcrumb also passes through `SentryTokenScrubber.beforeBreadcrumb`, which masks tokens that look like tokens, but that is a net and not a licence: keep tokens, emails and query values out of the map. ## Gotchas | Mistake | Fix | |:--------|:----| | Expecting a plain `MagicEvent` to leave a breadcrumb | Add `implements ReportsBreadcrumb`; events that do not opt in are skipped | | Putting a secret in `breadcrumbData` | It is sent as written apart from token masking; whitelist only what is safe to store | | Expecting a breadcrumb without a DSN | `boot()` returns early when `Sentry.isEnabled` is false, so no listener is registered | ## Related Documentation - [Boot and the Provider](https://anilcancakir.com/magic/docs/sentry/basics/boot-and-provider.md): where the listener is registered. - [Token Scrubbing](https://anilcancakir.com/magic/docs/sentry/guides/token-scrubbing.md): the masking a breadcrumb passes through. --- # Token Scrubbing `SentryTokenScrubber` masks URL-borne tokens in everything Sentry is about to send, and `MagicSentry.run` installs it for you. - [Quick Reference](#quick-reference) - [Why It Exists](#why-it-exists) - [The Three Rules](#the-three-rules) - [What It Covers](#what-it-covers) - [Failing Closed](#failing-closed) - [Your Own Hooks](#your-own-hooks) - [Limits](#limits) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Quick Reference | Hook | Masks | |:-----|:------| | `beforeSend` | An event: `request.url`, `queryString`, `fragment`, the transaction name, `contexts.trace`, exception values and the message | | `beforeSendTransaction` | Everything `beforeSend` covers, plus every span's description and data (apps on `SentryTraceLifecycle.static`) | | `beforeSendSpan` | A streamed span: its name and every string or string-array attribute (the `sentry_flutter` 10 default) | | `beforeBreadcrumb` | A breadcrumb's `message` and every string in its `data`, plus the `url` of an `http` one | | `beforeSendLog` | A structured log (`Sentry.logger`): its `body` and every string or string-array attribute | All of them are static members of `SentryTokenScrubber`. `MagicSentry.run` calls `SentryTokenScrubber.install(options)` after your `configure` returns, so there is nothing to do. ## Why It Exists A Magic route such as `/invitations/:token/accept` puts a credential in the path, and a reset link puts one in the query. On web the SDK copies `window.location` into `request.url` and the pathname into the transaction name, and `sentry_dio` keeps the API path on every breadcrumb and span. A token reaches Sentry through half a dozen fields nobody thinks of as sensitive. ## The Three Rules Applied in this order: | Rule | What becomes `[Filtered]` or `{token}` | |:-----|:---------------------------------------| | Query values | The value of a `key=value` pair whose key contains `token`, `password`, `secret`, `signature`, `credential` or `api_key`, in any case, in the query or in a fragment that opens with a pair (`#access_token=...`). The key and every other pair stay | | Route-bound segments | A path segment sitting where a registered Magic route has a param whose name contains `token`. Routes come from `MagicRouter.instance.allRoutes`, so routes inside `MagicRoute.group(layout: ...)` count | | Opaque API segments | On an `http` breadcrumb or span URL, any path segment of 32 or more `[A-Za-z0-9_-]` characters becomes `{token}`. A UUID is exempt because it is an id, not a credential | The interceptor's own events and fingerprints go further and collapse numeric and UUID ids to `{id}` and drop the query, through `SentryTokenScrubber.scrubApiUrl`. ## What It Covers Every hook only masks and never drops: an event, breadcrumb or log the scrubber does not recognise passes through unchanged. Numeric ids and the `email` query key are left alone on purpose, since masking them would blind debugging without protecting a credential. A hash-routed URL (`https://host/#/invitations//accept`) is covered. ## Failing Closed A throwing hook is no place to fail. `sentry_flutter` 10 catches it and drops the event, breadcrumb or log, but sends a streamed span as it was, half scrubbed, and nothing promises another SDK version would not do the same for the rest. So when scrubbing itself throws, the scrubber replaces every field it would have covered with `[Filtered]` and still returns the event. A scrubber bug costs you detail, never a token and never the report. ## Your Own Hooks The scrubber runs first and hands its result to your hook, so your hook can drop or rewrite but never sees a raw token. Set app hooks inside `configure`: ```dart import 'package:sentry_flutter/sentry_flutter.dart'; void configure(SentryFlutterOptions options) { options.beforeSend = (SentryEvent event, Hint hint) { // Already masked by SentryTokenScrubber; return null here to drop it. return event; }; } ``` A hook assigned after `SentryFlutter.init` replaces the scrubber, which is the one way to turn it off by accident. ## Limits - A transaction's deprecated `extra` map is not scrubbed. The same data written with `setData` also lands in `contexts.trace.data`, which is, but keep tokens out of transaction data. - Only what a route or a key names is masked. A secret in a path segment shorter than 32 characters on a route with no `token` param is not recognised. - `breadcrumbData` from your own events is yours to whitelist; the scrubber is a net under it. ## Gotchas | Mistake | Fix | |:--------|:----| | Assigning a hook after `SentryFlutter.init` | Assign it inside `configure`, so the scrubber wraps it | | Naming a route param `:code` or `:hash` for a credential | Name it with `token` in it (`:resetToken`), or the route rule does not see it | | Calling `normalizeEndpoint` before sending a URL | It collapses ids only; use `SentryTokenScrubber.scrubApiUrl` | | Calling the hooks yourself in a test without routes | Register the routes first; the route rule reads `MagicRouter.instance.allRoutes` | ## Related Documentation - [Boot and the Provider](https://anilcancakir.com/magic/docs/sentry/basics/boot-and-provider.md): where `install` is called. - [HTTP Reporting](https://anilcancakir.com/magic/docs/sentry/basics/http-reporting.md): the endpoint the interceptor reports. - [Testing](https://anilcancakir.com/magic/docs/sentry/guides/testing.md): registering routes before calling a hook. --- # Testing Test code that uses `magic_sentry`: nothing to set up without a DSN, and a placeholder DSN when you need to read the scope. - [Quick Reference](#quick-reference) - [Without a DSN](#without-a-dsn) - [Reading the Scope](#reading-the-scope) - [Pure Members](#pure-members) - [A Spy Network Driver](#a-spy-network-driver) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Quick Reference | You want to check | Use | |:------------------|:----| | Your app boots with the provider registered | Nothing: `Sentry.isEnabled` is `false`, so `boot()` returns early | | Which user Sentry holds | `Sentry.init` with a placeholder DSN, then `Sentry.configureScope` | | Whether a status becomes an event | `SentryNetworkInterceptor.dispositionFor(status)` | | How an endpoint groups | `SentryNetworkInterceptor.normalizeEndpoint(url)` | | What a hook masks | `SentryTokenScrubber.beforeSend`, `beforeBreadcrumb` and the others, after registering routes | | What an event becomes | `EventBreadcrumbs.breadcrumbFor(event)` | No mocking library is needed: the package follows Magic's convention of faking through contract inheritance. ## Without a DSN With no `SENTRY_DSN` the SDK is inert, so a widget test that boots the app with `SentryServiceProvider` registered runs as if the package were absent. Do not call `Sentry.init` just to silence it. ## Reading the Scope `Sentry.configureScope` is a no-op while the SDK is off, so a test that reads the scope user needs a bound hub. A placeholder DSN is enough because nothing here sends an event: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; void main() { setUp(() async { MagicApp.reset(); Magic.flush(); await Sentry.init( (options) => options.dsn = 'https://public@o0.ingest.sentry.io/0', ); }); tearDown(() async { Auth.unfake(); await Sentry.close(); }); test('the scope starts with no user', () async { SentryUser? user; await Sentry.configureScope((scope) => user = scope.user); expect(user, isNull); }); } ``` Fake the auth state with `Auth.fake`, call `SentryUserContext.install()`, and read the scope the same way. Undo it in `tearDown` with `Auth.unfake()` and `Sentry.close()`. ## Pure Members Most of the package is plain functions you can call with no hub: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic_sentry/magic_sentry.dart'; void main() { test('a server fault earns an issue and a 404 does not', () { expect( SentryNetworkInterceptor.dispositionFor(503), SentryHttpDisposition.event, ); expect( SentryNetworkInterceptor.dispositionFor(404), SentryHttpDisposition.breadcrumb, ); }); test('numeric ids collapse so one endpoint is one issue', () { expect( SentryNetworkInterceptor.normalizeEndpoint('/monitors/123/checks?page=2'), '/monitors/{id}/checks', ); }); } ``` `SentryUserContext.userFor(user)` answers the Sentry user for a model, or `null`. The scrubber's route rule reads `MagicRouter.instance.allRoutes`, so register the routes first and reset them in `tearDown`: ```dart import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:magic_sentry/magic_sentry.dart'; void main() { setUp(() { MagicRouter.reset(); MagicRoute.page('/invitations/:token/accept', () => const SizedBox()); }); tearDown(MagicRouter.reset); test('a route-bound token segment is masked', () { expect( SentryTokenScrubber.scrubApiUrl('/invitations/abc123/accept'), '/invitations/[Filtered]/accept', ); }); } ``` ## A Spy Network Driver To assert what the provider wires, bind a `NetworkDriver` that records `addInterceptor` and leaves every other member unimplemented, then boot the provider and check the recorded list for a `SentryNetworkInterceptor`. `SentryNetworkInterceptor.reportedFailures` is a static set that survives between tests, so clear it in `setUp`. ## Gotchas | Mistake | Fix | |:--------|:----| | Reading the scope with the SDK off | Call `Sentry.init` with a placeholder DSN first; `configureScope` is a no-op otherwise | | Leaving a hub bound after a test | `await Sentry.close()` in `tearDown`, and `Auth.unfake()` | | A repeat failure turning into a breadcrumb in the next test | `SentryNetworkInterceptor.reportedFailures.clear()` in `setUp` | | A scrubber test that ignores routes | Register them with `MagicRoute.page` and call `MagicRouter.reset()` in `tearDown` | ## Related Documentation - [User Context](https://anilcancakir.com/magic/docs/sentry/basics/user-context.md): the scope user a test reads. - [HTTP Reporting](https://anilcancakir.com/magic/docs/sentry/basics/http-reporting.md): `reportedFailures` and the disposition rules. - [Token Scrubbing](https://anilcancakir.com/magic/docs/sentry/guides/token-scrubbing.md): the hooks and their rules.