diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8f9d2..9e6c201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- `EventBreadcrumbs.breadcrumbFor`: maps any dispatched event that implements magic's `ReportsBreadcrumb` (`breadcrumbCategory`, `breadcrumbMessage`, `breadcrumbData`) to a Sentry breadcrumb, null for every other event. `SentryServiceProvider.boot` wires it through `Event.listenAny`, gated on `Sentry.isEnabled` like the provider's other hooks, so any package or app event that opts in (`magic_deeplink`'s `DeeplinkOpened` and `DeeplinkNavigating`, for example) shows up in the breadcrumb trail with no extra wiring. (`lib/src/event_breadcrumbs.dart`) + +### Changed + +- `magic` floor moves `^0.0.21` to `^0.0.22`: `Event.listenAny` and `ReportsBreadcrumb`, which the event breadcrumbs call, first ship in magic 0.0.22. (`pubspec.yaml`) + ### Fixed +- `SentryServiceProvider._registerEventBreadcrumbs()` now keeps the remover callback returned by `Event.listenAny()` and calls it before registering a new listener. A second boot in the same isolate (a test suite) no longer registers the listener multiple times, which used to double-record breadcrumbs from the same event. - The barrel (`lib/magic_sentry.dart`) no longer exports `config/sentry.dart`. The published install stub puts the same top-level names (`configureSentry`, `sentryDsn`, `sentryEnabled`, the env key constants) into a consumer app's own `lib/config/sentry.dart`; exporting a package copy alongside that made the documented `main()` an ambiguous import that never compiled. The package no longer carries its own copy of that file; `assets/stubs/install/sentry_config.stub` is now the single source. - `SentryServiceProvider._registerNetworkInterceptor` no longer swallows a wiring failure when no `log` binding exists. It now always reports: `Log.warning` (with the stack trace) when `log` is bound, `debugPrint` otherwise. - `SentryUserContext.install()` is now idempotent: a second call on the same instance no longer adds a second listener to `Auth.stateNotifier`, which used to run `apply()` twice per auth-state bump. diff --git a/doc/getting-started/installation.md b/doc/getting-started/installation.md index c387150..b4652fe 100644 --- a/doc/getting-started/installation.md +++ b/doc/getting-started/installation.md @@ -195,3 +195,9 @@ stays empty while error reporting keeps working. `lib/src/sentry_network_interceptor.dart`. - **User context**: `SentryUserContext` follows `Auth.stateNotifier`; see `lib/src/sentry_user_context.dart` for the id/email/extras contract. +- **Event breadcrumbs**: any package or app event that implements magic's + `ReportsBreadcrumb` (`breadcrumbCategory`, `breadcrumbMessage`, + `breadcrumbData`) shows up in the breadcrumb trail automatically, no extra + wiring required. `magic_deeplink`'s `DeeplinkOpened` and + `DeeplinkNavigating` are two examples. See + `lib/src/event_breadcrumbs.dart`. diff --git a/lib/magic_sentry.dart b/lib/magic_sentry.dart index ddd5d13..f37238b 100644 --- a/lib/magic_sentry.dart +++ b/lib/magic_sentry.dart @@ -9,6 +9,7 @@ /// moment both are imported together, which never compiles. library; +export 'src/event_breadcrumbs.dart'; export 'src/magic_sentry.dart'; export 'src/sentry_network_interceptor.dart'; export 'src/sentry_service_provider.dart'; diff --git a/lib/src/event_breadcrumbs.dart b/lib/src/event_breadcrumbs.dart new file mode 100644 index 0000000..07f530d --- /dev/null +++ b/lib/src/event_breadcrumbs.dart @@ -0,0 +1,39 @@ +import 'package:magic/magic.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +/// Translates a dispatched [MagicEvent] into a Sentry [Breadcrumb], for +/// [SentryServiceProvider]'s wildcard listener. +/// +/// Pure and testable with no Sentry hub, the same shape as +/// `SentryUserContext.userFor`: an event opts in by implementing +/// [ReportsBreadcrumb], and nothing else (this package, the listener, any +/// other event) has to know that contract exists. +/// +/// `abstract final`: every member is static, so it can be neither +/// instantiated nor extended, without a private constructor nothing calls. +abstract final class EventBreadcrumbs { + /// The breadcrumb for [event], or null when [event] does not implement + /// [ReportsBreadcrumb]. + /// + /// [Breadcrumb.data] carries exactly [ReportsBreadcrumb.breadcrumbData], + /// which is a whitelist the event author already curated; this never adds + /// or drops a key from it. + static Breadcrumb? breadcrumbFor(MagicEvent event) { + if (event is! ReportsBreadcrumb) { + return null; + } + + // Explicit cast rather than relying on flow promotion: [MagicEvent] and + // [ReportsBreadcrumb] are unrelated types (an event opts in via + // `implements`, not inheritance), and Dart only promotes a variable to a + // subtype of its declared type. + final ReportsBreadcrumb crumb = event as ReportsBreadcrumb; + + return Breadcrumb( + category: crumb.breadcrumbCategory, + message: crumb.breadcrumbMessage, + data: crumb.breadcrumbData, + level: SentryLevel.info, + ); + } +} diff --git a/lib/src/sentry_service_provider.dart b/lib/src/sentry_service_provider.dart index 5fe360a..21b1b2c 100644 --- a/lib/src/sentry_service_provider.dart +++ b/lib/src/sentry_service_provider.dart @@ -3,6 +3,7 @@ import 'package:magic/magic.dart'; import 'package:sentry_dio/sentry_dio.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'event_breadcrumbs.dart'; import 'sentry_network_interceptor.dart'; import 'sentry_user_context.dart'; @@ -46,6 +47,12 @@ class SentryServiceProvider extends ServiceProvider { /// the host app supplied no [userId]. final SentryUserContext? _userContext; + /// The remover callback for the event breadcrumb listener, or null when no + /// listener is registered. Used to remove the previous listener before + /// registering a new one on boot, so a second boot in the same isolate (a + /// test suite booting the provider again) does not register it twice. + static void Function()? _eventBreadcrumbRemover; + @override void register() { // Nothing to bind: this provider only wires existing services together. @@ -59,6 +66,7 @@ class SentryServiceProvider extends ServiceProvider { _registerNetworkInterceptor(); _userContext?.install(); + _registerEventBreadcrumbs(); // Registered here rather than in `main()` because `MagicRouter` refuses // an observer once it has built its `routerConfig`, and that build @@ -68,6 +76,27 @@ class SentryServiceProvider extends ServiceProvider { MagicRouter.instance.addObserver(SentryNavigatorObserver()); } + /// Record a Sentry breadcrumb for every dispatched [ReportsBreadcrumb] + /// event. + /// + /// The listener runs on every dispatch (magic's wildcard hook), so an + /// event author opts in by implementing [ReportsBreadcrumb] and nothing + /// else here has to learn about the event's type. + /// + /// Removes any existing listener before registering, so a second boot in + /// the same isolate does not register it twice. + void _registerEventBreadcrumbs() { + _eventBreadcrumbRemover?.call(); + + _eventBreadcrumbRemover = Event.listenAny((event) { + final Breadcrumb? crumb = EventBreadcrumbs.breadcrumbFor(event); + + if (crumb != null) { + Sentry.addBreadcrumb(crumb); + } + }); + } + /// Attach Sentry to the network driver, in two layers. /// /// `addSentry()` (from `sentry_dio`) contributes the automatic half: an diff --git a/pubspec.yaml b/pubspec.yaml index 6a7817f..8dfe825 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,9 +20,10 @@ environment: dependencies: flutter: sdk: flutter - # Auth.stateNotifier and MagicRouter.instance.addObserver both land at - # 0.0.21; see the published API check under doc/getting-started/installation.md. - magic: ^0.0.21 + # Auth.stateNotifier and MagicRouter.instance.addObserver land at 0.0.21; + # Event.listenAny and ReportsBreadcrumb, which the event breadcrumbs need, + # land at 0.0.22. + magic: ^0.0.22 sentry_flutter: ^9.27.0 sentry_dio: ^9.27.0 diff --git a/test/event_breadcrumbs_test.dart b/test/event_breadcrumbs_test.dart new file mode 100644 index 0000000..b79c2ee --- /dev/null +++ b/test/event_breadcrumbs_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_sentry/magic_sentry.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +/// An event that opts into the breadcrumb trail. +class _DeeplinkOpened extends MagicEvent implements ReportsBreadcrumb { + _DeeplinkOpened(this.path); + + final String path; + + @override + String get breadcrumbCategory => 'deeplink.open'; + + @override + String get breadcrumbMessage => 'Deeplink opened'; + + @override + Map get breadcrumbData => {'path': path}; +} + +/// A plain event, carrying no [ReportsBreadcrumb] contract. +class _PlainEvent extends MagicEvent {} + +/// Locks what [EventBreadcrumbs.breadcrumbFor] reports and what it ignores. +/// +/// Pure and testable with no Sentry hub, the same shape as +/// `SentryUserContext.userFor`: the mapping is asserted directly, without +/// ever sending a real breadcrumb. +void main() { + group('EventBreadcrumbs.breadcrumbFor', () { + test('maps a ReportsBreadcrumb event to its category, message and data', + () { + final Breadcrumb? crumb = + EventBreadcrumbs.breadcrumbFor(_DeeplinkOpened('/monitors/42')); + + expect(crumb, isNotNull); + expect(crumb!.category, 'deeplink.open'); + expect(crumb.message, 'Deeplink opened'); + expect(crumb.data, {'path': '/monitors/42'}); + expect(crumb.level, SentryLevel.info); + }); + + test('answers null for an event that does not implement ReportsBreadcrumb', + () { + expect(EventBreadcrumbs.breadcrumbFor(_PlainEvent()), isNull); + }); + }); +} diff --git a/test/sentry_service_provider_test.dart b/test/sentry_service_provider_test.dart index ab6c793..08451a7 100644 --- a/test/sentry_service_provider_test.dart +++ b/test/sentry_service_provider_test.dart @@ -110,6 +110,18 @@ class _SpyNetworkDriver implements NetworkDriver { throw UnimplementedError(); } +/// An event that opts into the breadcrumb trail. +class _TestEventBreadcrumb extends MagicEvent implements ReportsBreadcrumb { + @override + String get breadcrumbCategory => 'test.category'; + + @override + String get breadcrumbMessage => 'Test message'; + + @override + Map get breadcrumbData => {}; +} + /// Locks what [SentryServiceProvider.boot] wires, and when. /// /// Everything it does is gated on `Sentry.isEnabled`, which is false in @@ -263,6 +275,38 @@ void main() { expect(log.entries.single.message, contains('sentry')); expect(log.entries.single.context, contains('stackTrace')); }); + + test( + 'registers the event breadcrumb listener once, not duplicated on ' + 'second boot', () async { + Magic.app.setInstance('network', _SpyNetworkDriver()); + + // 1. Boot the provider a first time. + await SentryServiceProvider(MagicApp.instance).boot(); + + // 2. Boot the provider a second time (simulating hot restart or test + // re-run). + await SentryServiceProvider(MagicApp.instance).boot(); + + // 3. Dispatch a single breadcrumb event. + await Event.dispatch(_TestEventBreadcrumb()); + + // 4. Collect the recorded breadcrumbs. + List breadcrumbs = const []; + await Sentry.configureScope( + (scope) => breadcrumbs = scope.breadcrumbs, + ); + + // 5. Verify only one breadcrumb was recorded, not two (from the + // duplicate listener). + expect( + breadcrumbs, + hasLength(1), + reason: 'the event breadcrumb listener must not be registered ' + 'twice on a second boot', + ); + expect(breadcrumbs.single.category, 'test.category'); + }); }); }); }