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
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: CI

on:
push:
branches: [main, master]
pull_request:
branches: [main, master]

jobs:
analyze-and-test:
name: Lint & Test
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7

- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true

- name: Install dependencies
run: flutter pub get

- name: Analyze code
run: flutter analyze --no-fatal-infos

- name: Check formatting
run: dart format --set-exit-if-changed .

- name: Run tests
run: flutter test --coverage

- name: Upload coverage
uses: codecov/codecov-action@v7
with:
files: coverage/lcov.info
fail_ci_if_error: false
37 changes: 37 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Publish to pub.dev

on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+*'

jobs:
validate:
name: Validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true

- name: Install dependencies
run: flutter pub get

- name: Analyze code
run: flutter analyze --no-fatal-infos

- name: Check formatting
run: dart format --set-exit-if-changed .

- name: Run tests
run: flutter test

publish:
needs: validate
permissions:
id-token: write # Required for authentication using OIDC
uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1.8.1
40 changes: 40 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/

# IntelliJ related
*.iml
*.ipr
*.iws
.idea/

# VS Code
.vscode/

# Test coverage
coverage/

# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
pubspec.lock

# AI coding tools
.ac/
# Local development path wiring (never committed; breaks CI which has no sibling paths)
pubspec_overrides.yaml
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed

- 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.

### Added

- Initial release: `MagicSentry.run` (zone-wrapped `SentryFlutter.init` boot, web-safe per flutter/flutter#100277) and `MagicSentry.installErrorWidgetBreadcrumb` (tags a build error that replaced the interface).
- `SentryServiceProvider<T>`: wires `SentryNetworkInterceptor` into magic's `Http` driver (with `sentry_dio`'s own failed-request capture disabled to avoid double reporting), installs `SentryUserContext<T>` on `Auth.stateNotifier` when a host app supplies `userId`/`userEmail`, and registers `SentryNavigatorObserver` on `MagicRouter`.
- `SentryNetworkInterceptor`: reports a 5xx or a dead connection as an event fingerprinted by the normalised endpoint, a 4xx as a breadcrumb, and deduplicates repeats within a session.
- `SentryUserContext<T>`: keeps Sentry's scope user (id + email) and optional extra tags in step with the host app's own `Authenticatable` model.
- `lib/config/sentry.dart` default `configureSentry`, reading `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_RELEASE` and `SENTRY_TRACES_SAMPLE_RATE` from `.env`; published to a consumer project via `install.yaml` as an editable starting point.
120 changes: 120 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Magic Sentry Plugin

Sentry error and performance monitoring for the Magic Framework.

**Version:** 0.0.1 · **Dart:** >=3.6.0 · **Flutter:** >=3.27.0

## Commands

**Host-dispatched via `fluttersdk_artisan`'s generic manifest installer**, no
package-specific CLI provider:

| Command | Description |
|---------|-------------|
| `flutter test --coverage` | Run all tests with coverage |
| `flutter analyze --no-fatal-infos` | Static analysis |
| `dart format .` | Format all code |
| `dart run <app>:artisan plugin:install magic_sentry` | Publish `lib/config/sentry.dart` and inject `SentryServiceProvider` into the consumer's `lib/config/app.dart` |

## Architecture

**Pattern**: zone-wrapped boot helper + ServiceProvider, no Driver/Handler chain (there is only one transport, Sentry's own).

```
lib/
├── magic_sentry.dart # Barrel export
└── src/
├── magic_sentry.dart # MagicSentry.run + error widget breadcrumb
├── sentry_service_provider.dart # SentryServiceProvider<T>: wires boot()
├── sentry_network_interceptor.dart # MagicNetworkInterceptor -> Sentry events/breadcrumbs
└── sentry_user_context.dart # Auth.stateNotifier -> Sentry scope user
install.yaml # Plugin manifest: config publish, provider injection
assets/stubs/install/sentry_config.stub # configureSentry, published to a consumer's
# own lib/config/sentry.dart
```

**The barrel exports no config.** `configureSentry`/`sentryDsn`/`sentryEnabled`
and the env key constants live ONLY in the file `plugin:install` publishes into
a consumer app (`lib/config/sentry.dart`, generated from the stub above); the
package itself carries no copy. A package-side export of those same names
would collide with the consumer's own `import 'config/sentry.dart'` the moment
both are imported together, which is exactly what the documented `main()`
does, and `configureSentry` would become an ambiguous import that never
compiles.

**Why the config is not a magic config factory.** `MagicSentry.run` has to start
Sentry BEFORE `Magic.init` runs, so a build failure during boot is reported
too. At that point `Config` (magic's config repository) is not loaded, so
`configureSentry` reads `.env` directly through `Env`, never through
`Config.get`. `install.yaml` therefore has no `magic.config_factory` key,
unlike a plugin (e.g. `magic_deeplink`) whose config folds into
`Magic.init(configFactories: [...])`.

**Data flow:** `main()` calls `MagicSentry.run(configure: configureSentry,
appRunner: _boot)` → one zone opens (`runZonedGuarded`) → `WidgetsFlutterBinding
.ensureInitialized()` → `Env.load()` → `SentryFlutter.init(configure,
appRunner: _boot)` → `_boot` runs `Magic.init(...)` then `runApp(...)` →
`SentryServiceProvider.boot()` (registered like any other provider) wires the
network interceptor, the user context, and the navigator observer, all gated
on `Sentry.isEnabled`.

**User reporting is opt-in and generic.** This package has no model of its
own and never invents a "team" concept: `SentryServiceProvider<T>` takes
`userId`/`userEmail`/`userExtras` callbacks over the host app's own
`Authenticatable` model `T`; `userId` alone turns reporting on (email is
optional). Leave `userId` out to skip user reporting; the network interceptor
and navigator observer still wire up.

## Post-Change Checklist

After ANY source code change, sync **before committing**:

1. **`CHANGELOG.md`**: Add entry under `[Unreleased]` section
2. **`README.md`**: Update if features, API, or usage changes
3. **`doc/`**: Update relevant documentation files

## Development Flow (TDD)

Every feature, fix, or refactor must go through the red-green-refactor cycle:

1. **Red**: Write a failing test that describes the expected behavior
2. **Green**: Write the minimum code to make the test pass
3. **Refactor**: Clean up while keeping tests green

**Rules:**
- No production code without a failing test first
- Run `flutter test` after every change: all tests must stay green
- Run `dart analyze` after every change: zero warnings, zero errors
- Run `dart format .` before committing: zero formatting issues

## Testing

- Mock via contract inheritance (no mockito): a `NetworkDriver` test double
`implements NetworkDriver` and throws `UnimplementedError` on members the
test does not exercise, rather than a generated mock.
- `MagicApp.reset(); Magic.flush();` in `setUp`, the house pattern also used
by `magic` itself, so a previous test's singleton never leaks into the next.
- `MagicRouter.reset()` around any test that calls `addObserver`: it is a
process-global singleton with no per-test isolation otherwise.
- A test that observes Sentry's scope (the user, a tag, a breadcrumb) calls
`Sentry.init` with a syntactically valid but unreachable DSN first
(`https://public@o0.ingest.sentry.io/0`); nothing in this package's tests
ever sends a real event, so no network call happens regardless.

## Key Gotchas

| Mistake | Fix |
|---------|-----|
| Reading `sendDefaultPii`/session replay from `.env` | Never do this; both stay hardcoded off in `configureSentry`. An app that wants either overrides its own `configure` callback explicitly |
| `sentry_dio`'s `addSentry()` AND `SentryNetworkInterceptor` both capturing failed requests | `SentryServiceProvider` disables `captureFailedRequests` on `addSentry()`; `SentryNetworkInterceptor` is the only layer that decides event vs breadcrumb |
| Calling `MagicRouter.instance.addObserver` after `routerConfig` was built | Register `SentryServiceProvider` in `lib/config/app.dart`'s `providers` list, which boots before any route is built |
| A guarded network/log call throwing anyway | `Magic.bound('log')`/`try`/`catch` around `app.make<NetworkDriver>('network')`, matching magic_deeplink's pattern for an optional dependency that must not take the whole boot down |
| Team logic, tags, or breadcrumbs specific to one app | Does not belong here; use `SentryServiceProvider`'s `userExtras` callback instead |

## Skills & Extensions

- `fluttersdk:magic-framework`: Magic Framework patterns: facades, service providers, IoC, Eloquent ORM. Use for ANY code touching Magic APIs.

## CI

- `ci.yml`: push/PR → `flutter pub get` → `flutter analyze --no-fatal-infos` → `dart format --set-exit-if-changed` → `flutter test --coverage` → codecov upload
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Magic Sentry Plugin

Sentry error and performance monitoring for the [Magic Framework](https://magic.fluttersdk.com). Wraps `sentry_flutter` boot in a web-safe zone, reports the HTTP failures magic's `Http` facade swallows into `MagicResponse` values, keeps Sentry's scope user in step with `Auth.stateNotifier`, and registers a navigator observer on `MagicRouter`.

**Version:** 0.0.1 · **Dart:** >=3.6.0 · **Flutter:** >=3.27.0

## Install

```bash
flutter pub add magic_sentry
dart run <app>:artisan plugin:install magic_sentry
```

`plugin:install` (from `fluttersdk_artisan`, no package-specific CLI needed) reads this package's `install.yaml`, publishes `lib/config/sentry.dart` (documents the `.env` keys read at boot: `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_RELEASE`, `SENTRY_TRACES_SAMPLE_RATE`), and injects `SentryServiceProvider` into `lib/config/app.dart`'s provider list. See [doc/getting-started/installation.md](doc/getting-started/installation.md) for the full walkthrough, including the `main.dart` wiring the installer cannot do for you.

## Quick start

```dart
// main.dart
import 'config/sentry.dart';

void main() {
MagicSentry.run(configure: configureSentry, appRunner: _boot);
}

Future<void> _boot() async {
MagicSentry.installErrorWidgetBreadcrumb();
await Magic.init(configFactories: [...]);
runApp(MyApp());
}
```

```dart
// lib/config/app.dart
(app) => SentryServiceProvider<User>(
app,
userId: (user) => user.id,
userEmail: (user) => user.email,
),
```

## Architecture

```
lib/
├── magic_sentry.dart # Barrel export
└── src/
├── magic_sentry.dart # MagicSentry.run + error widget breadcrumb
├── sentry_service_provider.dart # SentryServiceProvider<T>: wires boot()
├── sentry_network_interceptor.dart # MagicNetworkInterceptor -> Sentry events/breadcrumbs
└── sentry_user_context.dart # Auth.stateNotifier -> Sentry scope user
install.yaml # Plugin manifest: config publish, provider injection
assets/stubs/install/sentry_config.stub # configureSentry, published to a consumer's
# own lib/config/sentry.dart; the package
# itself carries no copy of it (see below)
```

The barrel does not export `configureSentry`/`sentryDsn`/`sentryEnabled`: those
names live only in the file `plugin:install` publishes into a consumer app
(`lib/config/sentry.dart`, from the stub above). A package copy exported
alongside it would make the documented `main()` (which imports both the
barrel and that local file) an ambiguous import.

## Key decisions

- **Not enabled by default.** `sentryEnabled`/`Sentry.isEnabled` answer false with an empty `SENTRY_DSN`, and every integration in this package degrades to a no-op rather than throwing.
- **`sendDefaultPii` and session replay are never turned on by this package.** An app that wants either does so explicitly in its own `configure` callback.
- **HTTP double-reporting is avoided on purpose.** `SentryServiceProvider` disables `sentry_dio`'s own `captureFailedRequests`, since `SentryNetworkInterceptor` is the layer that decides event vs breadcrumb.
- **User reporting is opt-in and generic.** This package has no model of its own; pass `userId` (plus `userEmail` when your model has one, and optionally `userExtras`) for your own `Authenticatable` model, or leave `userId` out to skip user reporting entirely.

See [doc/getting-started/installation.md](doc/getting-started/installation.md) for configuration details.
13 changes: 13 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
include: package:flutter_lints/flutter.yaml

analyzer:
errors:
unintended_html_in_doc_comment: ignore
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
Loading
Loading