Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions doc/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,9 @@ stays empty while error reporting keeps working.
`lib/src/sentry_network_interceptor.dart`.
- **User context**: `SentryUserContext<T>` 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`.
1 change: 1 addition & 0 deletions lib/magic_sentry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
39 changes: 39 additions & 0 deletions lib/src/event_breadcrumbs.dart
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
29 changes: 29 additions & 0 deletions lib/src/sentry_service_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -46,6 +47,12 @@ class SentryServiceProvider<T extends Model> extends ServiceProvider {
/// the host app supplied no [userId].
final SentryUserContext<T>? _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.
Expand All @@ -59,6 +66,7 @@ class SentryServiceProvider<T extends Model> 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
Expand All @@ -68,6 +76,27 @@ class SentryServiceProvider<T extends Model> 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
Expand Down
7 changes: 4 additions & 3 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 49 additions & 0 deletions test/event_breadcrumbs_test.dart
Original file line number Diff line number Diff line change
@@ -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<String, Object?> 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);
});
});
}
44 changes: 44 additions & 0 deletions test/sentry_service_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object?> get breadcrumbData => {};
}

/// Locks what [SentryServiceProvider.boot] wires, and when.
///
/// Everything it does is gated on `Sentry.isEnabled`, which is false in
Expand Down Expand Up @@ -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<Breadcrumb> 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');
});
});
});
}
Loading