diff --git a/.env.example b/.env.example index f684bd4..bef2bf6 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,7 @@ # ======================================== SENTRY_AUTH_TOKEN=your_auth_token SENTRY_DSN=your_dsn -SENTRY_RELEASE=com.example.empower_flutter@9.22.0+1 # Format: @version+build (matches the build identifier used by Build Distribution) +SENTRY_RELEASE=com.example.empower_flutter@9.25.0+1 # Format: @version+build (matches the build identifier used by Build Distribution) SENTRY_ENVIRONMENT=your_environment # ======================================== diff --git a/CLAUDE.md b/CLAUDE.md index 41228e1..6e3e951 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ ## Project Overview **Application:** Empower Plant (`empower_flutter`) -**Version:** 9.22.0+1 (matches Sentry SDK version) +**Version:** 9.25.0+1 (matches Sentry SDK version) **Purpose:** Production-ready Flutter e-commerce app with comprehensive Sentry instrumentation demonstrating best practices for error monitoring, performance tracking, session replay, and user feedback. **Type:** Full-featured plant shopping app + Sentry demo platform @@ -24,7 +24,7 @@ - Sentry config: DSN, ORG, PROJECT, AUTH_TOKEN required ### Version Management -- **Format:** `package@version+build` (e.g., `com.example.empower_flutter@9.22.0+1`) +- **Format:** `package@version+build` (e.g., `com.example.empower_flutter@9.25.0+1`) - Version source: `pubspec.yaml` - Must match Sentry SDK version for consistency - Distribution set to build number (currently `'1'`) @@ -88,19 +88,19 @@ Flutter SDK: >= 3.22.0 < 4.0.0 Dart SDK: >= 3.5.0 < 4.0.0 # Sentry -sentry_flutter: ^9.22.0 # Main SDK -sentry_dio: ^9.22.0 # HTTP client integration -sentry_file: ^9.22.0 # File I/O tracking -sentry_logging: ^9.22.0 # Logging integration +sentry_flutter: ^9.25.0 # Main SDK +sentry_dio: ^9.25.0 # HTTP client integration +sentry_file: ^9.25.0 # File I/O tracking +sentry_logging: ^9.25.0 # Logging integration # State Management & Utils provider: ^6.1.5 # State management -flutter_dotenv: ^6.0.0 # Environment variables -dio: ^5.9.1 # HTTP client +flutter_dotenv: ^6.0.1 # Environment variables +dio: ^5.10.0 # HTTP client logging: ^1.3.0 # Structured logging # Web View feature -webview_flutter: ^4.13.0 # In-app WebView (Android/iOS) +webview_flutter: ^4.14.1 # In-app WebView (Android/iOS) url_launcher: ^6.3.1 # Desktop fallback (system browser) web: ^1.1.1 # Flutter web iframe support ``` @@ -130,6 +130,12 @@ sampleRate: 1.0 # All errors captured tracesSampleRate: 1.0 # All performance traces profilesSampleRate: 1.0 # All profiling (iOS/macOS/Android) +// Span Streaming API (stable since SDK 9.23.0) +traceLifecycle: SentryTraceLifecycle.stream # Send each span as it finishes +// NOTE: mutually-exclusive switch. In `stream` mode manual spans MUST use the +// `Sentry.startSpan` / `startSpanSync` callback API; the legacy +// `Sentry.startTransaction` / `startChild` APIs are no-ops. + // Session Replay (100% for demo) replay.onErrorSampleRate: 1.0 # All error sessions replay.sessionSampleRate: 1.0 # All normal sessions @@ -182,6 +188,7 @@ tracePropagationTargets: [ # Backends that receive trace headers **Session Replay Privacy Masking:** - Real prices are shown in full in the app UI (e.g. cart item lines and subtotal show `$155.00`); they are masked only in Session Replay - The `maskCallback` masks Text widgets containing a `$…` value so financial values stay private in replays +- Image masking is explicitly disabled (`maskAllImages = false`, `maskAssetImages = false`) so product photos, icons, and badges render normally in replays — otherwise every image blanks into a redaction box and the replay looks broken - Everything else in replays is visible ### Integrations Enabled @@ -238,16 +245,32 @@ try { ``` ### Performance Instrumentation -```dart -// Transaction -final transaction = Sentry.startTransaction('operation_name', 'operation_type'); -// Span -final span = transaction.startChild('child_operation', description: 'Details'); -// ... work ... -await span.finish(); +Uses the Span Streaming API (`traceLifecycle = SentryTraceLifecycle.stream`). +Manual spans use the `Sentry.startSpan` callback API — the span ends automatically +when the callback returns, and its status is set to `error` if the callback throws. +The legacy `Sentry.startTransaction` / `startChild` APIs are no-ops in stream mode. -await transaction.finish(status: SpanStatus.ok()); +```dart +// Root span (own trace): pass parentSpan: null. Nested startSpan calls +// automatically parent to the active span via zones. +await Sentry.startSpan('operation_name', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('operation_type')); + span.setAttribute('key', SentryAttribute.string('value')); + + await Sentry.startSpan('child_operation', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('child_type')); + // ... work ... + // On failure inside a caught block: child.status = SentrySpanStatusV2.error; + }); +}, parentSpan: null); + +// For a span that must outlive a single callback (widget lifecycle, stream +// subscription, platform round-trip): startInactiveSpan + manual end(). +final span = Sentry.startInactiveSpan('name', parentSpan: null); +// ... later ... +span.status = SentrySpanStatusV2.ok; +span.end(); ``` ### Structured Logging @@ -359,7 +382,7 @@ await client.get(Uri.parse('https://example.com')); ```bash SENTRY_AUTH_TOKEN=sntryu_xxx # Sentry auth token SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx -SENTRY_RELEASE=com.example.empower_flutter@9.22.0+1 +SENTRY_RELEASE=com.example.empower_flutter@9.25.0+1 SENTRY_ENVIRONMENT=development # development/staging/production SENTRY_ORG=your-org-slug SENTRY_PROJECT=your-project-slug @@ -484,9 +507,9 @@ flutter analyze 4. Use `Sentry.captureException()` to capture **Add Performance Instrumentation:** -1. Start transaction: `Sentry.startTransaction()` -2. Add spans for sub-operations -3. Finish with appropriate status +1. Start a root span: `await Sentry.startSpan('name', (span) async {...}, parentSpan: null)` +2. Add nested `Sentry.startSpan(...)` calls for sub-operations (auto-parented via zones) +3. Set the op with `span.setAttribute('sentry.op', SentryAttribute.string('...'))`; status is automatic (error on throw) 4. Add metrics if needed --- @@ -580,9 +603,9 @@ SENTRY_SIZE_ANALYSIS_ENABLED=true - **GitHub (Sentry SDK):** `https://github.com/getsentry/sentry-dart` ### Important Version Numbers -- **App Version:** 9.22.0+1 +- **App Version:** 9.25.0+1 - **Flutter SDK:** >= 3.22.0 -- **Sentry SDK:** ^9.22.0 +- **Sentry SDK:** ^9.25.0 ### File Locations - **Config:** `.env`, `lib/se_config.dart`, `pubspec.yaml` @@ -597,4 +620,4 @@ SENTRY_SIZE_ANALYSIS_ENABLED=true *Last Updated: Session creating this CLAUDE.md* *Current Branch: feature/comprehensive-sentry-integration* -*App Version: 9.22.0+1* +*App Version: 9.25.0+1* diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 85d2520..070854a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + id("io.sentry.android.gradle") } android { @@ -43,3 +44,28 @@ android { flutter { source = "../.." } + +// Sentry Android Gradle plugin — scoped to ProGuard/R8 mapping handling only. +// It embeds a per-build ProGuard UUID into the APK and uploads the matching +// mapping during the release Gradle build, so Sentry can de-obfuscate DEX for +// both crash reports and the size-analysis breakdown. Org/project/token are +// read from the environment (demo.sh exports them via `.env`); when they're +// absent (e.g. a plain local build) the upload is simply skipped. +sentry { + System.getenv("SENTRY_ORG")?.let { org.set(it) } + System.getenv("SENTRY_PROJECT")?.let { projectName.set(it) } + System.getenv("SENTRY_AUTH_TOKEN")?.let { authToken.set(it) } + + // Embed the mapping UUID into the APK and upload the mapping keyed by it. + includeProguardMapping.set(true) + autoUploadProguardMapping.set(true) + + // This app uses the sentry_flutter SDK, so the AGP must NOT add its own + // native SDK or instrument bytecode (avoids duplicate/conflicting setup). + autoInstallation { + enabled.set(false) + } + tracingInstrumentation { + enabled.set(false) + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 43394ed..44578d3 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -20,6 +20,9 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false + // Embeds the R8/ProGuard UUID into the APK and uploads the matching mapping + // so Sentry can de-obfuscate DEX (crash reports + size-analysis breakdown). + id("io.sentry.android.gradle") version "6.15.0" apply false } include(":app") diff --git a/demo.sh b/demo.sh index 64eeebe..b1685b8 100755 --- a/demo.sh +++ b/demo.sh @@ -480,22 +480,15 @@ build_windows() { print_success "Windows app built: build/windows/x64/runner/Release/" } -# Upload ProGuard mapping for Android builds (must be called before size analysis) +# ProGuard/R8 mapping upload is handled by the Sentry Android Gradle plugin +# (android/app/build.gradle.kts), NOT here. The plugin embeds a per-build UUID +# into the APK and uploads the matching mapping during the Gradle build, so +# Sentry can de-obfuscate DEX for crash reports AND the size-analysis breakdown. +# Uploading the mapping manually here (via `sentry-cli upload-proguard`) keyed it +# by the file's own UUID, which the APK didn't reference — leaving it orphaned +# ("Missing proguard mapping" in size analysis). This is now a no-op. upload_proguard_mapping() { - local proguard_mapping="build/app/outputs/mapping/release/mapping.txt" - if [ -f "$proguard_mapping" ]; then - print_info "Uploading ProGuard mapping for detailed DEX breakdown..." - if check_sentry_cli; then - if sentry-cli upload-proguard --org "${SENTRY_ORG}" --project "${SENTRY_PROJECT}" "$proguard_mapping" > /tmp/proguard_upload.log 2>&1; then - print_success "ProGuard mapping uploaded ($(du -h "$proguard_mapping" | cut -f1))" - else - print_warning "ProGuard mapping upload failed" - cat /tmp/proguard_upload.log - fi - fi - else - print_info "ProGuard mapping not found, skipping" - fi + print_info "ProGuard mapping upload handled by the Sentry Android Gradle plugin (during build)" } # Upload debug symbols to Sentry diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 93c0de4..aa2660b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,7 +3,7 @@ PODS: - package_info_plus (0.4.5): - Flutter - Sentry/HybridSDK (8.58.3) - - sentry_flutter (9.22.0): + - sentry_flutter (9.24.0): - Flutter - FlutterMacOS - Sentry/HybridSDK (= 8.58.3) @@ -40,7 +40,7 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 Sentry: 108fdbb76299c4189af12246bf0308c09c278922 - sentry_flutter: fbb8a76a5a009ce7ba7bde295ee6b50b11916851 + sentry_flutter: 8528594bf73819ef3f40c2e218e93554fc2719e6 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d diff --git a/lib/main.dart b/lib/main.dart index 072206d..690a351 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -248,26 +248,23 @@ class _HomePageState extends State { Future instrumentedOperation( String name, - Future Function(ISentrySpan span) operation, + Future Function(SentrySpanV2 span) operation, ) async { - final transaction = Sentry.startTransaction( - name, - 'custom', - bindToScope: true, - ); - // Example custom performance metrics - transaction.setMeasurement('memoryUsed', 123); - transaction.setMeasurement('ui.footerComponent.render', 1.3); - transaction.setMeasurement('localStorageRead', 4); - try { - await operation(transaction); - } catch (exception, stackTrace) { - transaction.throwable = exception; - transaction.status = SpanStatus.internalError(); - await Sentry.captureException(exception, stackTrace: stackTrace); - } finally { - await transaction.finish(); - } + await Sentry.startSpan(name, (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('custom')); + // Example custom performance measurements. `setMeasurement` was a v1 + // transaction-only concept; in the span-streaming API these are recorded + // as span attributes. + span.setAttribute('measurement.memoryUsed', SentryAttribute.int(123)); + span.setAttribute('measurement.ui.footerComponent.render', SentryAttribute.double(1.3)); + span.setAttribute('measurement.localStorageRead', SentryAttribute.int(4)); + try { + await operation(span); + } catch (exception, stackTrace) { + span.status = SentrySpanStatusV2.error; + await Sentry.captureException(exception, stackTrace: stackTrace); + } + }, parentSpan: null); } // Example usage for widget build instrumentation @@ -279,8 +276,8 @@ class InstrumentedDestinationView extends StatelessWidget { Widget build(BuildContext context) { return FutureBuilder( future: instrumentedOperation('build_${destination.title}', (span) async { - span.setData('destination_icon', destination.icon.toString()); - span.setData('destination_title', destination.title); + span.setAttribute('destination_icon', SentryAttribute.string(destination.icon.toString())); + span.setAttribute('destination_title', SentryAttribute.string(destination.title)); // Simulate build work await Future.delayed(const Duration(milliseconds: 10)); }), diff --git a/lib/navbar_destination.dart b/lib/navbar_destination.dart index e487b24..1765ebe 100644 --- a/lib/navbar_destination.dart +++ b/lib/navbar_destination.dart @@ -56,19 +56,15 @@ class _DestinationViewState extends State { title: Text('Back'), trailing: Icon(Icons.arrow_forward), onTap: () async { - final transaction = Sentry.startTransaction( - 'drawer.back', - 'ui.action', - ); - final span = transaction.startChild( - 'ui.action', - description: 'User navigates back from Drawer', - ); - transaction.setData('plant_action', 'navigate_back'); - span.setData('drawer', 'back_button'); Navigator.pop(context); - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('drawer.back', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('ui.action')); + span.setAttribute('plant_action', SentryAttribute.string('navigate_back')); + await Sentry.startSpan('User navigates back from Drawer', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('ui.action')); + child.setAttribute('drawer', SentryAttribute.string('back_button')); + }); + }, parentSpan: null); }, ), // Web View — opens empower-plant.com with Flutter-side @@ -131,45 +127,37 @@ class _DestinationViewState extends State { ListTile( title: Text('ANR (Android)'), onTap: () async { - final transaction = Sentry.startTransaction( - 'drawer.anr', - 'ui.action', - ); - final span = transaction.startChild( - 'main.thread.block', - description: 'Simulate ANR: Watering plant too long', - ); - transaction.setData('plant_action', 'simulate_anr'); - span.setData('duration', 10); Navigator.pop(context); - // Block main thread for 10 seconds to trigger ANR - final start = DateTime.now(); - while (DateTime.now().difference(start).inSeconds < 10) {} - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('drawer.anr', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('ui.action')); + span.setAttribute('plant_action', SentryAttribute.string('simulate_anr')); + await Sentry.startSpan('Simulate ANR: Watering plant too long', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('main.thread.block')); + child.setAttribute('duration', SentryAttribute.int(10)); + // Block main thread for 10 seconds to trigger ANR + final start = DateTime.now(); + while (DateTime.now().difference(start).inSeconds < 10) {} + }); + }, parentSpan: null); }, ), if (isIOS || isMacOS) ListTile( title: Text('App Hang (iOS/macOS)'), onTap: () async { - final transaction = Sentry.startTransaction( - 'drawer.app_hang', - 'ui.action', - ); - final span = transaction.startChild( - 'main.thread.block', - description: 'Simulate App Hang: Plant processing stuck', - ); - transaction.setData('plant_action', 'simulate_app_hang'); - span.setData('duration', 3); Navigator.pop(context); - // Block main thread for 3 seconds to trigger App Hang - // (App hang threshold is 2 seconds by default) - final start = DateTime.now(); - while (DateTime.now().difference(start).inSeconds < 3) {} - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('drawer.app_hang', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('ui.action')); + span.setAttribute('plant_action', SentryAttribute.string('simulate_app_hang')); + await Sentry.startSpan('Simulate App Hang: Plant processing stuck', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('main.thread.block')); + child.setAttribute('duration', SentryAttribute.int(3)); + // Block main thread for 3 seconds to trigger App Hang + // (App hang threshold is 2 seconds by default) + final start = DateTime.now(); + while (DateTime.now().difference(start).inSeconds < 3) {} + }); + }, parentSpan: null); }, ), ], @@ -185,34 +173,27 @@ class _DestinationViewState extends State { } Future execute(String method) async { - final transaction = Sentry.startTransaction( - 'execute.$method', - 'ui.action', - ); - final span = transaction.startChild( - 'method.channel.invoke', - description: 'Execute method: $method', - ); - transaction.setData('method', method); - span.setData('channel', 'example.flutter.sentry.io'); - - try { - await channel.invokeMethod(method); - span.status = SpanStatus.ok(); - } catch (error, stackTrace) { - span.throwable = error; - span.status = SpanStatus.internalError(); - final eventId = await Sentry.captureException( - error, - stackTrace: stackTrace, - ); - // Show user feedback dialog for Dart exceptions - if (mounted && error is! PlatformException) { - showUserFeedbackDialog(context, eventId); - } - } finally { - await span.finish(); - await transaction.finish(); - } + await Sentry.startSpan('execute.$method', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('ui.action')); + span.setAttribute('method', SentryAttribute.string(method)); + await Sentry.startSpan('Execute method: $method', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('method.channel.invoke')); + child.setAttribute('channel', SentryAttribute.string('example.flutter.sentry.io')); + try { + await channel.invokeMethod(method); + // Success status is applied automatically when the callback returns. + } catch (error, stackTrace) { + child.status = SentrySpanStatusV2.error; + final eventId = await Sentry.captureException( + error, + stackTrace: stackTrace, + ); + // Show user feedback dialog for Dart exceptions + if (mounted && error is! PlatformException) { + showUserFeedbackDialog(context, eventId); + } + } + }); + }, parentSpan: null); } } diff --git a/lib/platform/file_io_demo_io.dart b/lib/platform/file_io_demo_io.dart index 2c5d211..d620fa6 100644 --- a/lib/platform/file_io_demo_io.dart +++ b/lib/platform/file_io_demo_io.dart @@ -19,25 +19,19 @@ Future sentryFileExample() async { final file = File('my_file.txt'); final sentryFile = file.sentryTrace(); - final transaction = Sentry.startTransaction( - 'file_operations_example', - 'file.io', - bindToScope: true, - ); - - try { - await sentryFile.create(); - await sentryFile.writeAsString('Hello World'); - final text = await sentryFile.readAsString(); - if (kDebugMode) { - print(text); + await Sentry.startSpan('file_operations_example', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('file.io')); + try { + await sentryFile.create(); + await sentryFile.writeAsString('Hello World'); + final text = await sentryFile.readAsString(); + if (kDebugMode) { + print(text); + } + await sentryFile.delete(); + } catch (error, stackTrace) { + span.status = SentrySpanStatusV2.error; + await Sentry.captureException(error, stackTrace: stackTrace); } - await sentryFile.delete(); - await transaction.finish(status: SpanStatus.ok()); - } catch (error, stackTrace) { - transaction.throwable = error; - transaction.status = SpanStatus.internalError(); - await Sentry.captureException(error, stackTrace: stackTrace); - await transaction.finish(); - } + }, parentSpan: null); } diff --git a/lib/product_list.dart b/lib/product_list.dart index 0c2e58a..2f72324 100644 --- a/lib/product_list.dart +++ b/lib/product_list.dart @@ -169,371 +169,295 @@ class _ItemListState extends State { // Trigger C++ Segfault on startup Future _triggerCppSegfault() async { - final transaction = Sentry.startTransaction( - 'startup.cpp_segfault', - 'error', - ); - final span = transaction.startChild( - 'native.crash', - description: 'Trigger C++ Segfault: Plant root failure', - ); - transaction.setData('plant_action', 'cpp_segfault'); - span.setData('triggered_on', 'startup'); - try { - await channel.invokeMethod('cppSegfault'); - } catch (error, stackTrace) { - span.throwable = error; - span.status = SpanStatus.internalError(); - await Sentry.captureException(error, stackTrace: stackTrace); - } - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.cpp_segfault', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('error')); + span.setAttribute('plant_action', SentryAttribute.string('cpp_segfault')); + await Sentry.startSpan('Trigger C++ Segfault: Plant root failure', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('native.crash')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + try { + await channel.invokeMethod('cppSegfault'); + } catch (error, stackTrace) { + child.status = SentrySpanStatusV2.error; + await Sentry.captureException(error, stackTrace: stackTrace); + } + }); + }, parentSpan: null); } // Trigger Kotlin Exception on startup (Android only) Future _triggerKotlinException() async { if (!isAndroid) return; - final transaction = Sentry.startTransaction( - 'startup.kotlin_exception', - 'error', - ); - final span = transaction.startChild( - 'native.exception', - description: 'Trigger Kotlin Exception: Plant leaf error', - ); - transaction.setData('plant_action', 'kotlin_exception'); - span.setData('triggered_on', 'startup'); - try { - await channel.invokeMethod('kotlinException'); - } catch (error, stackTrace) { - span.throwable = error; - span.status = SpanStatus.internalError(); - await Sentry.captureException(error, stackTrace: stackTrace); - } - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.kotlin_exception', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('error')); + span.setAttribute('plant_action', SentryAttribute.string('kotlin_exception')); + await Sentry.startSpan('Trigger Kotlin Exception: Plant leaf error', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('native.exception')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + try { + await channel.invokeMethod('kotlinException'); + } catch (error, stackTrace) { + child.status = SentrySpanStatusV2.error; + await Sentry.captureException(error, stackTrace: stackTrace); + } + }); + }, parentSpan: null); } // Trigger Dart Exception on startup Future _triggerDartException() async { - final transaction = Sentry.startTransaction( - 'startup.dart_exception', - 'error', - ); - final span = transaction.startChild( - 'dart.exception', - description: 'Trigger Dart Exception: Plant soil error', - ); - transaction.setData('plant_action', 'dart_exception'); - span.setData('triggered_on', 'startup'); - try { - throw Exception('Simulated Dart Exception'); - } catch (error, stackTrace) { - span.throwable = error; - span.status = SpanStatus.internalError(); - await Sentry.captureException(error, stackTrace: stackTrace); - } - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.dart_exception', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('error')); + span.setAttribute('plant_action', SentryAttribute.string('dart_exception')); + await Sentry.startSpan('Trigger Dart Exception: Plant soil error', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('dart.exception')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + try { + throw Exception('Simulated Dart Exception'); + } catch (error, stackTrace) { + child.status = SentrySpanStatusV2.error; + await Sentry.captureException(error, stackTrace: stackTrace); + } + }); + }, parentSpan: null); } // Trigger Timeout Exception on startup Future _triggerTimeoutException() async { - final transaction = Sentry.startTransaction( - 'startup.timeout_exception', - 'error', - ); - final span = transaction.startChild( - 'dart.timeout', - description: 'Trigger Timeout: Plant growth timeout', - ); - transaction.setData('plant_action', 'timeout_exception'); - span.setData('triggered_on', 'startup'); - try { - throw TimeoutException('Operation timed out', Duration(seconds: 2)); - } catch (error, stackTrace) { - span.throwable = error; - span.status = SpanStatus.internalError(); - await Sentry.captureException(error, stackTrace: stackTrace); - } - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.timeout_exception', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('error')); + span.setAttribute('plant_action', SentryAttribute.string('timeout_exception')); + await Sentry.startSpan('Trigger Timeout: Plant growth timeout', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('dart.timeout')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + try { + throw TimeoutException('Operation timed out', Duration(seconds: 2)); + } catch (error, stackTrace) { + child.status = SentrySpanStatusV2.error; + await Sentry.captureException(error, stackTrace: stackTrace); + } + }); + }, parentSpan: null); } // Trigger Platform Exception on startup Future _triggerPlatformException() async { - final transaction = Sentry.startTransaction( - 'startup.platform_exception', - 'error', - ); - final span = transaction.startChild( - 'platform.exception', - description: 'Trigger Platform Exception: Plant pot error', - ); - transaction.setData('plant_action', 'platform_exception'); - span.setData('triggered_on', 'startup'); - try { - throw PlatformException( - code: 'PLATFORM_ERROR', - message: 'Simulated platform error', - ); - } catch (error, stackTrace) { - span.throwable = error; - span.status = SpanStatus.internalError(); - await Sentry.captureException(error, stackTrace: stackTrace); - } - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.platform_exception', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('error')); + span.setAttribute('plant_action', SentryAttribute.string('platform_exception')); + await Sentry.startSpan('Trigger Platform Exception: Plant pot error', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('platform.exception')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + try { + throw PlatformException( + code: 'PLATFORM_ERROR', + message: 'Simulated platform error', + ); + } catch (error, stackTrace) { + child.status = SentrySpanStatusV2.error; + await Sentry.captureException(error, stackTrace: stackTrace); + } + }); + }, parentSpan: null); } // Trigger Missing Plugin Exception on startup Future _triggerMissingPluginException() async { - final transaction = Sentry.startTransaction( - 'startup.missing_plugin_exception', - 'error', - ); - final span = transaction.startChild( - 'plugin.exception', - description: 'Trigger Missing Plugin: Plant fertilizer missing', - ); - transaction.setData('plant_action', 'missing_plugin_exception'); - span.setData('triggered_on', 'startup'); - try { - throw MissingPluginException('Simulated missing plugin'); - } catch (error, stackTrace) { - span.throwable = error; - span.status = SpanStatus.internalError(); - await Sentry.captureException(error, stackTrace: stackTrace); - } - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.missing_plugin_exception', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('error')); + span.setAttribute('plant_action', SentryAttribute.string('missing_plugin_exception')); + await Sentry.startSpan('Trigger Missing Plugin: Plant fertilizer missing', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('plugin.exception')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + try { + throw MissingPluginException('Simulated missing plugin'); + } catch (error, stackTrace) { + child.status = SentrySpanStatusV2.error; + await Sentry.captureException(error, stackTrace: stackTrace); + } + }); + }, parentSpan: null); } // Trigger Assertion Error on startup Future _triggerAssertionError() async { - final transaction = Sentry.startTransaction( - 'startup.assertion_error', - 'error', - ); - final span = transaction.startChild( - 'dart.assertion', - description: 'Trigger Assertion Error: Plant sunlight assertion', - ); - transaction.setData('plant_action', 'assertion_error'); - span.setData('triggered_on', 'startup'); - try { - assert(false, 'Simulated assertion error'); - } catch (error, stackTrace) { - span.throwable = error; - span.status = SpanStatus.internalError(); - await Sentry.captureException(error, stackTrace: stackTrace); - } - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.assertion_error', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('error')); + span.setAttribute('plant_action', SentryAttribute.string('assertion_error')); + await Sentry.startSpan('Trigger Assertion Error: Plant sunlight assertion', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('dart.assertion')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + try { + assert(false, 'Simulated assertion error'); + } catch (error, stackTrace) { + child.status = SentrySpanStatusV2.error; + await Sentry.captureException(error, stackTrace: stackTrace); + } + }); + }, parentSpan: null); } // Trigger State Error on startup Future _triggerStateError() async { - final transaction = Sentry.startTransaction( - 'startup.state_error', - 'error', - ); - final span = transaction.startChild( - 'dart.state', - description: 'Trigger State Error: Plant state error', - ); - transaction.setData('plant_action', 'state_error'); - span.setData('triggered_on', 'startup'); - try { - throw StateError('Simulated state error'); - } catch (error, stackTrace) { - span.throwable = error; - span.status = SpanStatus.internalError(); - await Sentry.captureException(error, stackTrace: stackTrace); - } - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.state_error', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('error')); + span.setAttribute('plant_action', SentryAttribute.string('state_error')); + await Sentry.startSpan('Trigger State Error: Plant state error', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('dart.state')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + try { + throw StateError('Simulated state error'); + } catch (error, stackTrace) { + child.status = SentrySpanStatusV2.error; + await Sentry.captureException(error, stackTrace: stackTrace); + } + }); + }, parentSpan: null); } // Trigger Range Error on startup Future _triggerRangeError() async { - final transaction = Sentry.startTransaction( - 'startup.range_error', - 'error', - ); - final span = transaction.startChild( - 'dart.range', - description: 'Trigger Range Error: Plant root range error', - ); - transaction.setData('plant_action', 'range_error'); - span.setData('triggered_on', 'startup'); - try { - throw RangeError('Simulated range error'); - } catch (error, stackTrace) { - span.throwable = error; - span.status = SpanStatus.internalError(); - await Sentry.captureException(error, stackTrace: stackTrace); - } - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.range_error', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('error')); + span.setAttribute('plant_action', SentryAttribute.string('range_error')); + await Sentry.startSpan('Trigger Range Error: Plant root range error', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('dart.range')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + try { + throw RangeError('Simulated range error'); + } catch (error, stackTrace) { + child.status = SentrySpanStatusV2.error; + await Sentry.captureException(error, stackTrace: stackTrace); + } + }); + }, parentSpan: null); } // Trigger Type Error on startup Future _triggerTypeError() async { - final transaction = Sentry.startTransaction( - 'startup.type_error', - 'error', - ); - final span = transaction.startChild( - 'dart.type', - description: 'Trigger Type Error: Plant type error', - ); - transaction.setData('plant_action', 'type_error'); - span.setData('triggered_on', 'startup'); - try { - throw TypeError(); - } catch (error, stackTrace) { - span.throwable = error; - span.status = SpanStatus.internalError(); - await Sentry.captureException(error, stackTrace: stackTrace); - } - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.type_error', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('error')); + span.setAttribute('plant_action', SentryAttribute.string('type_error')); + await Sentry.startSpan('Trigger Type Error: Plant type error', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('dart.type')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + try { + throw TypeError(); + } catch (error, stackTrace) { + child.status = SentrySpanStatusV2.error; + await Sentry.captureException(error, stackTrace: stackTrace); + } + }); + }, parentSpan: null); } // Trigger Image Decoding on Main Thread Future _triggerImageDecoding() async { - final transaction = Sentry.startTransaction( - 'startup.image_decode_main_thread', - 'performance', - bindToScope: true, - ); - final span = transaction.startChild( - 'image.decode.main_thread', - description: 'Large Image Decoding on Main Thread', - ); - transaction.setData('plant_action', 'image_decode_main_thread'); - span.setData('triggered_on', 'startup'); - - // Simulate image decoding by creating large in-memory data - final imageData = List.filled(1000000, 255); - imageData.reduce((a, b) => a + b); - - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.image_decode_main_thread', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('performance')); + span.setAttribute('plant_action', SentryAttribute.string('image_decode_main_thread')); + await Sentry.startSpan('Large Image Decoding on Main Thread', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('image.decode.main_thread')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + + // Simulate image decoding by creating large in-memory data + final imageData = List.filled(1000000, 255); + imageData.reduce((a, b) => a + b); + }); + }, parentSpan: null); } // Trigger Regex on Main Thread Future _triggerRegex() async { - final transaction = Sentry.startTransaction( - 'startup.regex_main_thread', - 'performance', - bindToScope: true, - ); - final span = transaction.startChild( - 'regex.main_thread', - description: 'Complex Regex on Main Thread', - ); - transaction.setData('plant_action', 'regex_main_thread'); - span.setData('triggered_on', 'startup'); - - // Perform complex regex operations on main thread - final text = List.filled(10000, 'Plant name: Monstera deliciosa, Price: \$25.99. ').join(); - final regex = RegExp(r'\b[A-Z][a-z]+ [a-z]+\b'); - regex.allMatches(text).toList(); - - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.regex_main_thread', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('performance')); + span.setAttribute('plant_action', SentryAttribute.string('regex_main_thread')); + await Sentry.startSpan('Complex Regex on Main Thread', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('regex.main_thread')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + + // Perform complex regex operations on main thread + final text = List.filled(10000, 'Plant name: Monstera deliciosa, Price: \$25.99. ').join(); + final regex = RegExp(r'\b[A-Z][a-z]+ [a-z]+\b'); + regex.allMatches(text).toList(); + }); + }, parentSpan: null); } // Trigger N+1 API Calls Future _triggerNPlusOneAPICalls() async { - final transaction = Sentry.startTransaction( - 'startup.n_plus_one_api_calls', - 'performance', - bindToScope: true, - ); - transaction.setData('plant_action', 'n_plus_one_api_calls'); - - // Make sequential calls to trigger N+1 detection - // SentryHttpClient automatically creates http.client spans - final url = 'https://httpbin.org/delay/0'; // Use faster endpoint for demo - final apiClient = SentryHttpClient(); - - try { - // Make 15 sequential GET requests (required for N+1 detection) - for (int i = 0; i < 15; i++) { - final uri = Uri.parse('$url?id=$i'); - try { - await apiClient.get(uri); - } catch (e, st) { - await Sentry.captureException(e, stackTrace: st); + await Sentry.startSpan('startup.n_plus_one_api_calls', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('performance')); + span.setAttribute('plant_action', SentryAttribute.string('n_plus_one_api_calls')); + + // Make sequential calls to trigger N+1 detection. + // SentryHttpClient automatically creates child http.client spans under the + // currently-active span (this one, propagated via the startSpan zone). + final url = 'https://httpbin.org/delay/0'; // Use faster endpoint for demo + final apiClient = SentryHttpClient(); + + try { + // Make 15 sequential GET requests (required for N+1 detection) + for (int i = 0; i < 15; i++) { + final uri = Uri.parse('$url?id=$i'); + try { + await apiClient.get(uri); + } catch (e, st) { + await Sentry.captureException(e, stackTrace: st); + } } + } finally { + apiClient.close(); } - } finally { - apiClient.close(); - await transaction.finish(status: SpanStatus.ok()); - } + }, parentSpan: null); } // Trigger Frame Drop Future _triggerFrameDrop() async { - final transaction = Sentry.startTransaction( - 'startup.frame_drop', - 'performance', - bindToScope: true, - ); - final span = transaction.startChild( - 'ui.frame_drop', - description: 'Trigger Frame Drops', - ); - transaction.setData('plant_action', 'frame_drop'); - span.setData('triggered_on', 'startup'); - - // Perform heavy computation to cause frame drops - for (int i = 0; i < 5; i++) { - final start = DateTime.now(); - // Block for ~20ms to cause dropped frames (target is 16ms for 60fps) - while (DateTime.now().difference(start).inMilliseconds < 20) { - // Busy wait - } - await Future.delayed(Duration(milliseconds: 10)); - } - - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.frame_drop', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('performance')); + span.setAttribute('plant_action', SentryAttribute.string('frame_drop')); + await Sentry.startSpan('Trigger Frame Drops', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('ui.frame_drop')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + + // Perform heavy computation to cause frame drops + for (int i = 0; i < 5; i++) { + final start = DateTime.now(); + // Block for ~20ms to cause dropped frames (target is 16ms for 60fps) + while (DateTime.now().difference(start).inMilliseconds < 20) { + // Busy wait + } + await Future.delayed(Duration(milliseconds: 10)); + } + }); + }, parentSpan: null); } // Trigger Function Regression Future _triggerFunctionRegression() async { - final transaction = Sentry.startTransaction( - 'startup.function_regression', - 'performance', - bindToScope: true, - ); - final span = transaction.startChild( - 'function.slow', - description: 'Slow Function Performance Regression', - ); - transaction.setData('plant_action', 'function_regression'); - span.setData('triggered_on', 'startup'); - span.setData('duration_ms', 500); - - // Simulate a slow function that has regressed in performance - await Future.delayed(Duration(milliseconds: 500)); - - // Do some computation - int sum = 0; - for (int i = 0; i < 100000; i++) { - sum += i; - } - if (kDebugMode) { - print('Function regression completed, sum: $sum'); - } - - await span.finish(); - await transaction.finish(); + await Sentry.startSpan('startup.function_regression', (span) async { + span.setAttribute('sentry.op', SentryAttribute.string('performance')); + span.setAttribute('plant_action', SentryAttribute.string('function_regression')); + await Sentry.startSpan('Slow Function Performance Regression', (child) async { + child.setAttribute('sentry.op', SentryAttribute.string('function.slow')); + child.setAttribute('triggered_on', SentryAttribute.string('startup')); + child.setAttribute('duration_ms', SentryAttribute.int(500)); + + // Simulate a slow function that has regressed in performance + await Future.delayed(Duration(milliseconds: 500)); + + // Do some computation + int sum = 0; + for (int i = 0; i < 100000; i++) { + sum += i; + } + if (kDebugMode) { + print('Function regression completed, sum: $sum'); + } + }); + }, parentSpan: null); } @override diff --git a/lib/sentry_setup.dart b/lib/sentry_setup.dart index 5f92965..d89a234 100644 --- a/lib/sentry_setup.dart +++ b/lib/sentry_setup.dart @@ -58,10 +58,15 @@ Future initSentry({required VoidCallback appRunner}) async { const dartDefineDsn = String.fromEnvironment('SENTRY_DSN', defaultValue: ''); const dartDefineRelease = String.fromEnvironment('SENTRY_RELEASE', defaultValue: ''); const dartDefineEnvironment = String.fromEnvironment('SENTRY_ENVIRONMENT', defaultValue: ''); + // Verbose SDK logging is opt-in so you can watch envelopes being sent when + // verifying delivery: `flutter run --dart-define=SENTRY_DEBUG=true` + // (or set SENTRY_DEBUG=true in .env). Off by default so release builds stay quiet. + const dartDefineDebug = bool.fromEnvironment('SENTRY_DEBUG', defaultValue: false); final String? sentryDsn = dartDefineDsn.isNotEmpty ? dartDefineDsn : dotenv.env['SENTRY_DSN']; final String? sentryRelease = dartDefineRelease.isNotEmpty ? dartDefineRelease : dotenv.env['SENTRY_RELEASE']; final String? sentryEnvironment = dartDefineEnvironment.isNotEmpty ? dartDefineEnvironment : dotenv.env['SENTRY_ENVIRONMENT']; + final bool sentryDebug = dartDefineDebug || (dotenv.env['SENTRY_DEBUG']?.toLowerCase() == 'true'); await SentryFlutter.init((options) { // ======================================== @@ -70,17 +75,18 @@ Future initSentry({required VoidCallback appRunner}) async { options.dsn = sentryDsn; // Release must match exactly with uploaded debug symbols - // CRITICAL: This must be in format "appname@version+build" (e.g., "empower_flutter@9.14.0+1") + // CRITICAL: This must be in format "appname@version+build" (e.g., "empower_flutter@9.25.0+1") options.release = sentryRelease; options.environment = sentryEnvironment; // Set distribution to match build number for better symbol matching - options.dist = '1'; // Matches version 9.22.0+1 + options.dist = '1'; // Matches version 9.25.0+1 // Debug settings (disabled for production to ensure proper symbol resolution) - // Set to true only when debugging Sentry configuration issues - options.debug = false; - options.diagnosticLevel = SentryLevel.error; + // Toggle on via --dart-define=SENTRY_DEBUG=true (or SENTRY_DEBUG=true in .env) + // to see envelope transmission / diagnostics when verifying event delivery. + options.debug = sentryDebug; + options.diagnosticLevel = sentryDebug ? SentryLevel.debug : SentryLevel.error; // ======================================== // Sampling Configuration @@ -91,6 +97,13 @@ Future initSentry({required VoidCallback appRunner}) async { // Capture 100% of performance traces (adjust in production based on volume) options.tracesSampleRate = 1.0; + // Span Streaming API (stable since SDK 9.23.0). Each span is sent to Sentry + // as soon as it finishes, instead of being buffered into a transaction + // envelope. NOTE: this is a mutually-exclusive switch — in `stream` mode the + // manual spans must use the `Sentry.startSpan` / `startSpanSync` callback API; + // the legacy `Sentry.startTransaction` / `startChild` APIs are no-ops here. + options.traceLifecycle = SentryTraceLifecycle.stream; + // Enable profiling (relative to tracesSampleRate) // Required for JSON Decoding, Image Decoding, and Frame Drop detection // Profiling is available on iOS, macOS, and Android @@ -208,10 +221,10 @@ Future initSentry({required VoidCallback appRunner}) async { // ======================================== // Session Replay Privacy Configuration // ======================================== - // Mask all images (product photos) in session replays - options.privacy.maskAllImages = true; - // Also mask asset images (AssetImage widgets loaded from the bundle) - options.privacy.maskAssetImages = true; + // Keep images visible in replays so the app renders normally (product photos, + // icons, badges). Masking every image blanks the whole replay UI into boxes. + options.privacy.maskAllImages = false; + options.privacy.maskAssetImages = false; // Leave text unmasked by default — we use a callback to selectively mask prices options.privacy.maskAllText = false; @@ -261,6 +274,18 @@ Future initSentry({required VoidCallback appRunner}) async { scope.setTag('app.platform', platformName); scope.setTag('se', se); + // Scope TAGS aren't applied to spans in the Span Streaming API — only to + // error events. Set the same separation keys as scope ATTRIBUTES so the SDK + // auto-includes them on every streamed span, keeping Performance/Trace data + // filterable per engineer (and by customer type), consistent with errors. + // https://docs.sentry.io/platforms/dart/guides/flutter/tracing/streamed-spans/ + scope.setAttributes({ + 'se': SentryAttribute.string(se), + 'customerType': SentryAttribute.string(customerType), + 'app.flavor': SentryAttribute.string('demo'), + 'app.platform': SentryAttribute.string(platformName), + }); + // A baseline identified user so every event has user context (overridden // per-session in product_list with a randomized email to vary the cohort). scope.setUser(SentryUser( diff --git a/lib/webview/trace_headers.dart b/lib/webview/trace_headers.dart index 91f43e1..471ced4 100644 --- a/lib/webview/trace_headers.dart +++ b/lib/webview/trace_headers.dart @@ -1,30 +1,34 @@ import 'package:sentry_flutter/sentry_flutter.dart'; -/// Returns the `sentry-trace` (and `baggage`) headers for the currently active -/// span so a downstream page/request can continue the Flutter-initiated trace. +/// Returns the `sentry-trace` header for [span] so a downstream page/request can +/// continue the Flutter-initiated trace. /// -/// This is the Flutter side of distributed tracing: when the user opens the -/// Web View we attach these to the webview's HTTP request (mobile) or as query -/// params (web/desktop, where request headers can't be set). -Map currentTraceHeaders() { +/// This is the Flutter side of distributed tracing: when the user opens the Web +/// View we attach this to the webview's URL as a query param (browser JS can't +/// read request headers). We build the header from the span we explicitly hold +/// (see [withTraceQueryParams]) rather than from `Sentry.getSpan()`, because the +/// span-streaming API tracks the active span on a zone-forked scope, not on +/// `scope.span` — so the global getter would return null here. +/// +/// Note: only `sentry-trace` is propagated (not `baggage`/DSC). `sentry-trace` +/// is what links the web page's trace to this one; the streaming API doesn't +/// expose a public accessor for a span's dynamic sampling context, so baggage +/// is omitted. Traces still connect end-to-end via the shared trace id. +Map traceHeadersFor(SentrySpanV2? span) { final headers = {}; - final span = Sentry.getSpan(); if (span == null) return headers; - final trace = span.toSentryTrace(); + // Sampling is 100% in this demo (tracesSampleRate = 1.0), so the span is + // always sampled; mark the propagated trace accordingly. + final trace = SentryTraceHeader(span.traceId, span.spanId, sampled: true); headers[trace.name] = trace.value; // 'sentry-trace' - - final baggage = span.toBaggageHeader(); - if (baggage != null) { - headers[baggage.name] = baggage.value; // 'baggage' - } return headers; } -/// Appends the trace headers as URL query parameters. Used where HTTP request -/// headers can't be set (web iframe, external desktop browser). -Uri withTraceQueryParams(Uri uri) { - final headers = currentTraceHeaders(); +/// Appends [span]'s trace header as URL query parameters. Used where HTTP +/// request headers can't be set (web iframe, external desktop browser). +Uri withTraceQueryParams(Uri uri, SentrySpanV2? span) { + final headers = traceHeadersFor(span); if (headers.isEmpty) return uri; return uri.replace(queryParameters: { ...uri.queryParameters, diff --git a/lib/webview/web_view_io.dart b/lib/webview/web_view_io.dart index 2b9285d..5eb9804 100644 --- a/lib/webview/web_view_io.dart +++ b/lib/webview/web_view_io.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -15,17 +16,21 @@ Widget buildWebViewBody( BuildContext context, String url, { VoidCallback? onPageFinished, + SentrySpanV2? traceSpan, }) { if (isAndroid || isIOS) { - return _MobileWebView(url: url, onPageFinished: onPageFinished); + return _MobileWebView( + url: url, onPageFinished: onPageFinished, traceSpan: traceSpan); } - return _DesktopLauncher(url: url, onPageFinished: onPageFinished); + return _DesktopLauncher( + url: url, onPageFinished: onPageFinished, traceSpan: traceSpan); } class _MobileWebView extends StatefulWidget { final String url; final VoidCallback? onPageFinished; - const _MobileWebView({required this.url, this.onPageFinished}); + final SentrySpanV2? traceSpan; + const _MobileWebView({required this.url, this.onPageFinished, this.traceSpan}); @override State<_MobileWebView> createState() => _MobileWebViewState(); @@ -48,7 +53,7 @@ class _MobileWebViewState extends State<_MobileWebView> { ) // Trace rides in the URL (query params), not request headers, so the // loaded page's browser SDK can read and continue it. - ..loadRequest(withTraceQueryParams(Uri.parse(widget.url))); + ..loadRequest(withTraceQueryParams(Uri.parse(widget.url), widget.traceSpan)); } @override @@ -58,10 +63,12 @@ class _MobileWebViewState extends State<_MobileWebView> { class _DesktopLauncher extends StatelessWidget { final String url; final VoidCallback? onPageFinished; - const _DesktopLauncher({required this.url, this.onPageFinished}); + final SentrySpanV2? traceSpan; + const _DesktopLauncher( + {required this.url, this.onPageFinished, this.traceSpan}); Future _open() async { - final uri = withTraceQueryParams(Uri.parse(url)); + final uri = withTraceQueryParams(Uri.parse(url), traceSpan); await launchUrl(uri, mode: LaunchMode.externalApplication); } diff --git a/lib/webview/web_view_screen.dart b/lib/webview/web_view_screen.dart index 6319c19..9728d76 100644 --- a/lib/webview/web_view_screen.dart +++ b/lib/webview/web_view_screen.dart @@ -34,27 +34,28 @@ class WebViewScreen extends StatefulWidget { } class _WebViewScreenState extends State { - ISentrySpan? _transaction; + SentrySpanV2? _transaction; @override void initState() { super.initState(); - // Start the Web View journey on its OWN trace and bind it to the scope. - // We bind explicitly (bindToScope: true assigns scope.span unconditionally) - // rather than relying on SentryNavigatorObserver, which binds with `??=`: - // if a home/app-start span is still active, the observer wouldn't bind the - // webview transaction, and the platform impl's trace-header handoff would - // read that stale (home) span — making the loaded page continue the WRONG - // (home) trace. Owning + binding here guarantees the page continues THIS - // webview transaction's trace. + // Start the Web View journey on its OWN trace. A root span + // (`parentSpan: null`) adopts the scope's current propagation-context trace + // id, so we first `generateNewTrace()` to reset that to a brand-new trace — + // otherwise the journey would inherit the current screen's trace. + // We use `startInactiveSpan` because the span must outlive this method — + // it's held for the widget's lifetime and ended when the page loads (or on + // dispose). We hold the span explicitly and hand it to the platform impl + // for the trace-header handoff, guaranteeing the loaded page continues THIS + // webview span's trace. // ignore: invalid_use_of_internal_member Sentry.currentHub.generateNewTrace(); - final tx = Sentry.startTransaction( + final tx = Sentry.startInactiveSpan( WebViewScreen.transactionName, - 'navigation', - bindToScope: true, + parentSpan: null, ); - tx.setData('url', widget.url); + tx.setAttribute('sentry.op', SentryAttribute.string('navigation')); + tx.setAttribute('url', SentryAttribute.string(widget.url)); _transaction = tx; } @@ -62,8 +63,9 @@ class _WebViewScreenState extends State { // as a fallback if the user leaves before it loads. void _finishTransaction() { final tx = _transaction; - if (tx == null || tx.finished) return; - tx.finish(status: const SpanStatus.ok()); + if (tx == null || tx.isEnded) return; + tx.status = SentrySpanStatusV2.ok; + tx.end(); } @override @@ -82,6 +84,9 @@ class _WebViewScreenState extends State { // Finish the transaction when the page loads so it's sent and shows up // in the trace while the user is still viewing the Web View. onPageFinished: _finishTransaction, + // Hand this journey's span to the impl so the loaded page's URL carries + // the sentry-trace and continues THIS trace (Flutter → web). + traceSpan: _transaction, ), ); } diff --git a/lib/webview/web_view_web.dart b/lib/webview/web_view_web.dart index 3025fcc..7a912c4 100644 --- a/lib/webview/web_view_web.dart +++ b/lib/webview/web_view_web.dart @@ -1,6 +1,7 @@ import 'dart:ui_web' as ui_web; import 'package:flutter/material.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:web/web.dart' as web; import 'trace_headers.dart'; @@ -15,14 +16,17 @@ Widget buildWebViewBody( BuildContext context, String url, { VoidCallback? onPageFinished, + SentrySpanV2? traceSpan, }) { - return _IframeWebView(url: url, onPageFinished: onPageFinished); + return _IframeWebView( + url: url, onPageFinished: onPageFinished, traceSpan: traceSpan); } class _IframeWebView extends StatefulWidget { final String url; final VoidCallback? onPageFinished; - const _IframeWebView({required this.url, this.onPageFinished}); + final SentrySpanV2? traceSpan; + const _IframeWebView({required this.url, this.onPageFinished, this.traceSpan}); @override State<_IframeWebView> createState() => _IframeWebViewState(); @@ -35,7 +39,8 @@ class _IframeWebViewState extends State<_IframeWebView> { void initState() { super.initState(); _viewType = 'empower-webview-${_viewCounter++}'; - final src = withTraceQueryParams(Uri.parse(widget.url)).toString(); + final src = + withTraceQueryParams(Uri.parse(widget.url), widget.traceSpan).toString(); ui_web.platformViewRegistry.registerViewFactory( _viewType, diff --git a/pubspec.lock b/pubspec.lock index d321614..bdf3a3a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" boolean_selector: dependency: transitive description: @@ -77,26 +77,26 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" dio: dependency: "direct main" description: name: dio - sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 + sha256: ea2bad3c89a27635ce2d85cce4d6b199da49a5a48ec77b03e45b65a3b90922b0 url: "https://pub.dev" source: hosted - version: "5.9.1" + version: "5.10.0" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: dd58dc3861eb36edb13b217efc006a1c21e5bbc341de8c229b85634fa5e362e4 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.0" fake_async: dependency: transitive description: @@ -113,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -138,10 +146,10 @@ packages: dependency: "direct main" description: name: flutter_dotenv - sha256: d4130c4a43e0b13fefc593bc3961f2cb46e30cb79e253d4a526b1b5d24ae1ce4 + sha256: d41da11fb497314fbf89811ec30af02d1d898b47980a129f0a8c0a1720460ba2 url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.0.1" flutter_lints: dependency: "direct dev" description: @@ -300,18 +308,18 @@ packages: dependency: transitive description: name: package_info_plus - sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "10.2.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "4.1.0" path: dependency: transitive description: @@ -364,10 +372,10 @@ packages: dependency: transitive description: name: sentry - sha256: "3311f89e0c964c4df5f65670348c8a11744c94ae2b57032c947cfb26bfd03ef0" + sha256: a84bf3a83b3ce1c89fce28b9f97659e3826df14a73a256952465aac2c5efdf5a url: "https://pub.dev" source: hosted - version: "9.22.0" + version: "9.25.0" sentry_dart_plugin: dependency: "direct dev" description: @@ -380,34 +388,34 @@ packages: dependency: "direct main" description: name: sentry_dio - sha256: a0f0a832215346f117b4a10954301c1e1ac61f6df63a31f2fdff83e0f677d126 + sha256: "297caacf2ed037d859611d94165f35c34cf0462420aea1ceb1fed8c87a06f1dd" url: "https://pub.dev" source: hosted - version: "9.22.0" + version: "9.25.0" sentry_file: dependency: "direct main" description: name: sentry_file - sha256: "238e6e345abad10517bb448b6fbc4b39fce090ff33e0b9c7ed4956a7babce4f0" + sha256: "0e4a36c02901855a383972b210ec33fc97a2619d9e0a4f7562538ed2c8f735f1" url: "https://pub.dev" source: hosted - version: "9.22.0" + version: "9.25.0" sentry_flutter: dependency: "direct main" description: name: sentry_flutter - sha256: efd3bebd7962438d0ecbe5c7b530de012e2e8efe14ef838b89195d7c2bb3f66c + sha256: c85575266d91f57364e9b4cb522835156ec3c5581c78339dc7fae491cd0c6f76 url: "https://pub.dev" source: hosted - version: "9.22.0" + version: "9.25.0" sentry_logging: dependency: "direct main" description: name: sentry_logging - sha256: "62ace60242269eea62affbbe2ce863f9d8e4b28a41eca4dbc300f21b453700df" + sha256: "7506853de2fe61dd1d814b5266b24360635cd7fc7076c89ea3676f4b4ce2b948" url: "https://pub.dev" source: hosted - version: "9.22.0" + version: "9.25.0" sky_engine: dependency: transitive description: flutter @@ -545,10 +553,10 @@ packages: dependency: transitive description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.6.0" vector_math: dependency: transitive description: @@ -561,10 +569,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.2.0" web: dependency: "direct main" description: @@ -577,10 +585,10 @@ packages: dependency: "direct main" description: name: webview_flutter - sha256: e4d3474277c5043f5f3100ed27d94ad693abfd5c602b0221c719a4497e4ba1e4 + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 url: "https://pub.dev" source: hosted - version: "4.14.0" + version: "4.14.1" webview_flutter_android: dependency: transitive description: @@ -609,10 +617,10 @@ packages: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.3.0" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 031bcce..710f0e6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 9.22.0+1 +version: 9.25.0+1 environment: sdk: ">=3.5.0 <4.0.0" @@ -34,18 +34,18 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 + cupertino_icons: ^1.0.9 provider: ^6.1.5 - sentry_flutter: ^9.22.0 - flutter_dotenv: ^6.0.0 - sentry_dio: ^9.22.0 - dio: ^5.9.1 - sentry_file: ^9.22.0 - sentry_logging: ^9.22.0 + sentry_flutter: ^9.25.0 + flutter_dotenv: ^6.0.1 + sentry_dio: ^9.25.0 + dio: ^5.10.0 + sentry_file: ^9.25.0 + sentry_logging: ^9.25.0 logging: ^1.3.0 # Web View feature (Android/iOS in-app webview; desktop falls back to url_launcher; # web uses a dart:ui_web iframe via package:web) - webview_flutter: ^4.13.0 + webview_flutter: ^4.14.1 url_launcher: ^6.3.1 web: ^1.1.1