diff --git a/BUILD_GUIDE.md b/BUILD_GUIDE.md index 0f0dc88..92ea890 100644 --- a/BUILD_GUIDE.md +++ b/BUILD_GUIDE.md @@ -29,6 +29,8 @@ This guide explains how to use the unified `demo.sh` script to build your Flutte ```bash ./demo.sh build [platform] [build-type] ./demo.sh run [platform] +./demo.sh distribute [file] +./demo.sh upload-size [file] [platform] ./demo.sh verify ``` @@ -160,7 +162,7 @@ The script automatically uploads symbols for release builds if Sentry is configu Add to your `.env` file: ```bash SENTRY_DSN=https://your-key@o0.ingest.sentry.io/0000000 -SENTRY_RELEASE=myapp@9.14.0+1 +SENTRY_RELEASE=myapp@9.22.0+1 SENTRY_ENVIRONMENT=production ``` @@ -231,6 +233,46 @@ The script will automatically: For detailed instructions and CI/CD integration, see the [Size Analysis Guide](SIZE_ANALYSIS_GUIDE.md). +## Build Distribution + +Upload a built app to **Sentry Build Distribution** so testers can install it: + +```bash +# Android APK +./demo.sh distribute android + +# Android App Bundle +./demo.sh distribute aab + +# iOS +./demo.sh distribute ios + +# Or pass an explicit file +./demo.sh distribute android path/to/app-release.apk +``` + +The command uploads via `sentry-cli build upload` (resolving the default release artifact per platform), reusing the same uploader as size analysis. This is in addition to `./demo.sh upload-size`. + +## Platform Notes + +### Android Gradle Plugin +The project requires **AGP 8.9.1** (set in `android/settings.gradle.kts`), needed by the androidx libraries pulled in by `webview_flutter`/`url_launcher`. The Gradle wrapper is 8.12. + +### Web & macOS Support +Web and macOS now build and run: + +```bash +# Web +flutter build web +flutter run -d chrome + +# macOS +flutter run -d macos +``` + +- The shared code path is web-safe (`dart:io` removed via conditional-import modules in `lib/platform/`; platform checks use `kIsWeb`/`defaultTargetPlatform`). +- Sandboxed macOS apps need the `com.apple.security.network.client` entitlement (enabled in `macos/Runner/DebugProfile.entitlements` and `Release.entitlements`) to reach the network. + ## Troubleshooting ### Flutter Not Found diff --git a/CLAUDE.md b/CLAUDE.md index e2a8762..41228e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ ## Project Overview **Application:** Empower Plant (`empower_flutter`) -**Version:** 9.14.0+1 (matches Sentry SDK version) +**Version:** 9.22.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.14.0+1`) +- **Format:** `package@version+build` (e.g., `com.example.empower_flutter@9.22.0+1`) - Version source: `pubspec.yaml` - Must match Sentry SDK version for consistency - Distribution set to build number (currently `'1'`) @@ -42,8 +42,21 @@ lib/ ├── navbar_destination.dart # Navigation drawer + error triggers ├── product_list.dart # Home screen, catalog, demo triggers (750+ lines) ├── product_details.dart # Product detail view -├── cart.dart # Shopping cart +├── cart.dart # Shopping cart (shows real prices) ├── checkout.dart # Checkout flow with metrics/logging +├── backend_config.dart # Backend base URL selection (standard vs OTLP) +├── platform/ # Web-safe platform abstraction +│ ├── platform_info.dart # kIsWeb/defaultTargetPlatform helpers +│ ├── native_info_io.dart # dart:io-backed native info (mobile/desktop) +│ ├── native_info_web.dart # Web stub for native info +│ ├── file_io_demo.dart # Conditional-import entry for file I/O demo +│ ├── file_io_demo_io.dart # dart:io file I/O demo implementation +│ └── file_io_demo_web.dart # Web-safe file I/O demo stub +├── webview/ # Web View journey (distributed tracing) +│ ├── web_view_screen.dart # Web View drawer screen + transaction +│ ├── web_view_io.dart # In-app WebView (Android/iOS) / browser (desktop) +│ ├── web_view_web.dart # dart:ui_web iframe (Flutter web) +│ └── trace_headers.dart # Builds sentry-trace/baggage query params └── models/ └── cart_state_model.dart # Provider-based cart state @@ -75,22 +88,29 @@ Flutter SDK: >= 3.22.0 < 4.0.0 Dart SDK: >= 3.5.0 < 4.0.0 # Sentry -sentry_flutter: ^9.14.0 # Main SDK -sentry_dio: ^9.14.0 # HTTP client integration -sentry_file: ^9.14.0 # File I/O tracking -sentry_logging: ^9.14.0 # Logging integration +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 # State Management & Utils provider: ^6.1.5 # State management flutter_dotenv: ^6.0.0 # Environment variables dio: ^5.9.1 # HTTP client logging: ^1.3.0 # Structured logging + +# Web View feature +webview_flutter: ^4.13.0 # In-app WebView (Android/iOS) +url_launcher: ^6.3.1 # Desktop fallback (system browser) +web: ^1.1.1 # Flutter web iframe support ``` ### External Services -- **Backend API:** `https://flask.empower-plant.com/` (products, checkout) +- **Backend API (default):** `https://flask.empower-plant.com/` (products, checkout) +- **Backend API (OTLP):** `https://flask-otlp.empower-plant.com/` (OpenTelemetry-instrumented; used by the OTLP journey) +- **Web app (React):** `https://empower-plant.com/products` (loaded by the Web View journey) - **Sentry:** Error monitoring, performance, session replay -- **Sentry CLI:** Optional for symbol upload and size analysis +- **Sentry CLI:** Optional for symbol upload, size analysis, and build distribution --- @@ -143,6 +163,14 @@ enableAutoNativeBreadcrumbs: true # Native breadcrumbs // Privacy (Demo settings) sendDefaultPii: true # Send PII for demo environment + +// Distributed Tracing +tracePropagationTargets: [ # Backends that receive trace headers + 'empower-plant.com', + 'flask.empower-plant.com', + 'flask-otlp.empower-plant.com', + 'localhost', +] ``` ### Custom Hooks & Privacy @@ -152,9 +180,8 @@ sendDefaultPii: true # Send PII for demo environment - Sets fingerprint: `['{{ default }}', 'se:$se']` **Session Replay Privacy Masking:** -- Masks Text widgets containing BOTH a financial label AND dollar sign -- Financial labels: `items (`, `shipping & handling`, `total before tax`, `estimated tax`, `order total`, `subtotal` -- Ensures labels remain visible, only values with $ are masked +- 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 - Everything else in replays is visible ### Integrations Enabled @@ -283,6 +310,12 @@ await client.get(Uri.parse('https://example.com')); # Run on device and create Sentry deploy ./demo.sh run [platform] +# Upload a build for size analysis +./demo.sh upload-size [file] [platform] + +# Upload a build to Sentry Build Distribution +./demo.sh distribute [file] + # Verify setup (Flutter, Sentry CLI, .env) ./demo.sh verify @@ -290,6 +323,8 @@ await client.get(Uri.parse('https://example.com')); ./demo.sh help ``` +**Note:** `distribute` uploads a built app to Sentry Build Distribution via `sentry-cli build upload` (resolving the default release artifact per platform, reusing the same uploader as size analysis). + ### Supported Platforms | Platform | Command | Output Location | @@ -324,7 +359,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.14.0+1 +SENTRY_RELEASE=com.example.empower_flutter@9.22.0+1 SENTRY_ENVIRONMENT=development # development/staging/production SENTRY_ORG=your-org-slug SENTRY_PROJECT=your-project-slug @@ -377,6 +412,20 @@ SENTRY_SIZE_ANALYSIS_ENABLED=true # Optional: enable size tracking - Order value gauge metrics - User feedback collection on errors +### Web View Journey (Distributed Tracing) + +- Drawer item **Web View** opens `https://empower-plant.com/products` inside an in-app WebView (Android/iOS), a `dart:ui_web` iframe (Flutter web), or the system browser (desktop) +- Starts its own transaction `webview/empower-plant` (op `navigation`) on a fresh trace +- Implements **Flutter → web distributed tracing**: attaches the active `sentry-trace`/`baggage` to the loaded URL as query params so the web page's Sentry browser SDK continues the same trace (Flutter → React → backend = one trace) +- Transaction finishes when the page loads +- Code: `lib/webview/` (`web_view_screen.dart`, `web_view_io.dart`, `web_view_web.dart`, `trace_headers.dart`) + +### OTLP Backend Journey + +- Drawer item **OTLP** opens the home/product experience but routes all backend calls (`/products`, `/checkout`) to the OpenTelemetry-instrumented backend `https://flask-otlp.empower-plant.com` instead of the default `https://flask.empower-plant.com` +- Runs as its own new trace and continues into the OTLP backend via propagated trace headers +- Backend selection is centralized in `lib/backend_config.dart` (`BackendConfig.base`, `.products`, `.checkout`; standard vs OTLP base) + --- ## Development Workflow @@ -524,18 +573,23 @@ SENTRY_SIZE_ANALYSIS_ENABLED=true ## Quick Reference ### Key URLs -- **Backend API:** `https://flask.empower-plant.com/` +- **Backend API (default):** `https://flask.empower-plant.com/` +- **Backend API (OTLP):** `https://flask-otlp.empower-plant.com/` +- **Web app (React):** `https://empower-plant.com/products` - **Spotlight (Debug):** `http://localhost:8969/` - **GitHub (Sentry SDK):** `https://github.com/getsentry/sentry-dart` ### Important Version Numbers -- **App Version:** 9.14.0+1 +- **App Version:** 9.22.0+1 - **Flutter SDK:** >= 3.22.0 -- **Sentry SDK:** ^9.14.0 +- **Sentry SDK:** ^9.22.0 ### File Locations - **Config:** `.env`, `lib/se_config.dart`, `pubspec.yaml` - **Sentry Setup:** `lib/sentry_setup.dart` +- **Backend Selection:** `lib/backend_config.dart` +- **Platform Abstraction:** `lib/platform/` +- **Web View Journey:** `lib/webview/` - **Build Output:** `build/` directory - **Debug Symbols:** `build/debug-info/`, `build/app/obfuscation.map.json` @@ -543,4 +597,4 @@ SENTRY_SIZE_ANALYSIS_ENABLED=true *Last Updated: Session creating this CLAUDE.md* *Current Branch: feature/comprehensive-sentry-integration* -*App Version: 9.14.0+1* +*App Version: 9.22.0+1* diff --git a/README.md b/README.md index 70c9b7f..a010779 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A Flutter e-commerce application showcasing comprehensive Sentry instrumentation for error monitoring, performance tracking, session replay, and user feedback. -**For Solution Engineers:** This guide focuses on **Android** setup, which has been fully tested. iOS and other platforms have not been validated yet. +**For Solution Engineers:** This guide focuses on **Android** setup, which has been fully tested. **Web** and **macOS** now build and run as well (see "Other Platforms" below). iOS, Linux, and Windows have not been fully validated yet. --- @@ -33,7 +33,7 @@ A Flutter e-commerce application showcasing comprehensive Sentry instrumentation ```bash SENTRY_AUTH_TOKEN=sntryu_your_token_here SENTRY_DSN=https://your_key@o123456.ingest.us.sentry.io/123456 - SENTRY_RELEASE=com.example.empower_flutter@9.14.0+1 + SENTRY_RELEASE=com.example.empower_flutter@9.22.0+1 SENTRY_ENVIRONMENT=development SENTRY_ORG=your-org-slug SENTRY_PROJECT=your-project-slug @@ -131,7 +131,7 @@ Navigate through the app (Home → Product Details → Cart → Checkout) and ch - Trigger any error from the drawer menu - Go to Sentry Issues → Click on the error - View the attached session replay showing user actions leading up to the error -- Financial information (prices in checkout) is automatically masked for privacy +- Financial information (prices in cart/checkout) is shown in full in the app UI but automatically masked in Session Replay for privacy (the `maskCallback` in `lib/sentry_setup.dart` masks any `$…` Text) ### 4. Metrics & Logging @@ -150,6 +150,21 @@ Trigger from drawer menu: **ANR (Android)** - Creates ANR event in Sentry with thread states - Shows which operations were blocking +### 6. Web View (Distributed Tracing) — Cross-Platform + +Trigger from drawer menu: **Web View** +- Opens the Empower Plant React web app (`https://empower-plant.com/products`) inside an in-app WebView (Android/iOS), a `dart:ui_web` iframe (Flutter web), or the system browser (desktop) +- Starts its own Sentry transaction (`webview/empower-plant`, op `navigation`) on a fresh trace +- Performs **Flutter → web distributed tracing**: the active `sentry-trace`/`baggage` are attached to the loaded URL as query params so the web page's Sentry browser SDK continues the same trace (Flutter → React → backend = one trace) +- The transaction finishes when the page loads + +### 7. OTLP Backend Journey — Cross-Platform + +Trigger from drawer menu: **OTLP** +- Opens the home/product experience but routes all backend calls (`/products`, `/checkout`) to the OpenTelemetry-instrumented backend `https://flask-otlp.empower-plant.com` instead of the default `https://flask.empower-plant.com` +- Runs as its own new trace and continues into the OTLP backend via propagated trace headers +- Backend selection is centralized in `lib/backend_config.dart` + --- ## Demo Features Reference @@ -208,6 +223,16 @@ Both APK and AAB are uploaded with detailed DEX breakdown (thanks to ProGuard ma For more details, see [SIZE_ANALYSIS_GUIDE.md](SIZE_ANALYSIS_GUIDE.md). +## Build Distribution (Optional) + +Upload a built app to Sentry Build Distribution so testers can install it: + +```bash +./demo.sh distribute [file] +``` + +This uploads the build via `sentry-cli build upload` (resolving the default release artifact per platform), reusing the same uploader as size analysis. + --- ## Troubleshooting @@ -271,8 +296,11 @@ lib/ ├── navbar_destination.dart # Navigation drawer + error triggers ├── product_list.dart # Product catalog, performance demos ├── product_details.dart # Product detail view -├── cart.dart # Shopping cart +├── cart.dart # Shopping cart (shows real prices) ├── checkout.dart # Checkout with metrics/logging +├── backend_config.dart # Backend base URL selection (standard vs OTLP) +├── platform/ # Web-safe platform abstraction (conditional imports) +├── webview/ # Web View journey + Flutter→web trace handoff └── models/ └── cart_state_model.dart # Shopping cart state (Provider) @@ -325,27 +353,32 @@ All features are configured at 100% sampling for demo purposes. Adjust in `lib/s --- -## iOS & Other Platforms (Untested) +## Other Platforms + +In addition to Android, **Web and macOS now build and run**: + +```bash +# Web (build + run) +flutter build web +flutter run -d chrome + +# macOS +flutter run -d macos +``` -This demo has **only been validated on Android**. iOS, Web, macOS, Linux, and Windows builds may work but have not been tested by the team. +The shared code path is web-safe (`dart:io` removed via conditional-import modules in `lib/platform/`, platform checks use `kIsWeb`/`defaultTargetPlatform`). macOS apps are sandboxed, so the `com.apple.security.network.client` entitlement is enabled in `macos/Runner/DebugProfile.entitlements` and `Release.entitlements` so the app can reach the network. -If you want to try other platforms: +iOS, Linux, and Windows builds may work but have not been fully validated by the team: ```bash # iOS (requires macOS + Xcode) ./demo.sh build ios -# Web -./demo.sh build web - # Others -./demo.sh build macos ./demo.sh build linux ./demo.sh build windows ``` -Platform-specific documentation exists in the codebase but **is not guaranteed to be accurate**. - --- ## Support & Resources @@ -358,6 +391,6 @@ For issues with this demo, check existing documentation or reach out to the SE t --- -**Current Version:** 9.14.0+1 (matches Sentry SDK) -**Tested Platform:** Android only +**Current Version:** 9.22.0+1 (matches Sentry SDK) +**Tested Platforms:** Android (primary), Web, macOS **App Name:** Empower Plant (com.example.empower_flutter) diff --git a/SENTRY_FEATURES.md b/SENTRY_FEATURES.md index 97b55fd..b17c145 100644 --- a/SENTRY_FEATURES.md +++ b/SENTRY_FEATURES.md @@ -4,9 +4,10 @@ This document provides an overview of all Sentry features integrated into this F ## Overview -This application demonstrates comprehensive Sentry integration with the latest SDK (9.14.0) and best practices for: +This application demonstrates comprehensive Sentry integration with the latest SDK (9.22.0) and best practices for: - ✅ Error & Exception Tracking - ✅ Performance Monitoring (TTID, TTFD) +- ✅ Distributed Tracing (Flutter → web → backend) - ✅ Session Replay - ✅ User Feedback - ✅ Debug Symbol Upload @@ -126,6 +127,38 @@ final dio = Dio(); dio.addSentry(); ``` +## 2.5 Distributed Tracing + +### What It Does +- Connects a single trace across the Flutter app, the Empower Plant React web app, and the backend so one user journey shows as one end-to-end trace +- Trace headers (`sentry-trace`, `baggage`) propagate to the backends listed in `options.tracePropagationTargets` + +### Configuration +```dart +// lib/sentry_setup.dart +options.tracePropagationTargets = [ + 'empower-plant.com', + 'flask.empower-plant.com', + 'flask-otlp.empower-plant.com', + 'localhost', +]; +``` + +### Flutter → Web View → React → Backend (Web View Journey) +The drawer item **Web View** opens `https://empower-plant.com/products` inside an in-app WebView (Android/iOS), a `dart:ui_web` iframe (Flutter web), or the system browser (desktop). + +- Starts its own transaction `webview/empower-plant` (op `navigation`) on a fresh trace +- Attaches the active `sentry-trace`/`baggage` to the loaded URL as query params so the web page's Sentry browser SDK continues the same trace +- Result: Flutter → React → backend appears as a single distributed trace +- The transaction finishes when the page loads +- Code: `lib/webview/` (`web_view_screen.dart`, `web_view_io.dart`, `web_view_web.dart`, `trace_headers.dart`) + +### OTLP Backend Journey +The drawer item **OTLP** opens the home/product experience but routes all backend calls (`/products`, `/checkout`) to the OpenTelemetry-instrumented backend `https://flask-otlp.empower-plant.com` instead of the default `https://flask.empower-plant.com`. + +- Runs as its own new trace and continues into the OTLP backend via propagated trace headers +- Backend selection is centralized in `lib/backend_config.dart` + ## 3. Session Replay ### What It Does diff --git a/SIZE_ANALYSIS_GUIDE.md b/SIZE_ANALYSIS_GUIDE.md index 918e9fc..237c983 100644 --- a/SIZE_ANALYSIS_GUIDE.md +++ b/SIZE_ANALYSIS_GUIDE.md @@ -297,6 +297,16 @@ sentry-cli build upload YourApp.ipa \ --base-sha $(git merge-base HEAD origin/main) ``` +## Build Distribution + +The same `sentry-cli build upload` mechanism that powers size analysis is also used to upload builds to **Sentry Build Distribution**, so testers can install them. The `demo.sh` script exposes this via: + +```bash +./demo.sh distribute [file] +``` + +This resolves the default release artifact per platform (or uses an explicit `[file]`) and reuses the same uploader as size analysis. It is in addition to `./demo.sh upload-size`. + ## Troubleshooting ### Sentry CLI Not Found diff --git a/android/gradle.properties b/android/gradle.properties index f018a61..475a628 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ab39a10..43394ed 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,7 +18,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.3" apply false + id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false } diff --git a/demo.sh b/demo.sh index a33187a..64eeebe 100755 --- a/demo.sh +++ b/demo.sh @@ -49,7 +49,12 @@ print_header() { load_env() { if [ -f .env ]; then print_info "Loading environment variables from .env" - export $(grep -v '^#' .env | xargs) + # Source the file so inline comments (VALUE # note) and special chars + # in values (@, /, =, +) are handled correctly. `set -a` auto-exports. + set -a + # shellcheck disable=SC1091 + . ./.env + set +a print_success "Environment variables loaded" else print_warning ".env file not found, using system environment variables" @@ -750,7 +755,8 @@ upload_size_analysis() { # Add metadata [ -n "$head_sha" ] && cmd="$cmd --head-sha \"$head_sha\"" - [ -n "$base_sha" ] && cmd="$cmd --base-sha \"$base_sha\"" + # Only send base-sha when it differs from head-sha (Sentry rejects equal SHAs). + [ -n "$base_sha" ] && [ "$base_sha" != "$head_sha" ] && cmd="$cmd --base-sha \"$base_sha\"" [ -n "$head_ref" ] && cmd="$cmd --head-ref \"$head_ref\"" [ -n "$base_ref" ] && cmd="$cmd --base-ref \"$base_ref\"" [ -n "$vcs_provider" ] && cmd="$cmd --vcs-provider \"$vcs_provider\"" @@ -766,6 +772,50 @@ upload_size_analysis() { fi } +# ============================================================================ +# BUILD DISTRIBUTION +# ============================================================================ +# Uploads a built app to Sentry Build Distribution (and size analysis) using +# `sentry-cli build upload`. Resolves the default release artifact per platform. +# Docs: https://docs.sentry.io/platforms/dart/guides/flutter/build-distribution/ +distribute_build() { + local platform="$1" + local build_file="$2" + + # Resolve the default release artifact if a path wasn't provided. + if [ -z "$build_file" ]; then + case "$platform" in + android|apk) + build_file="build/app/outputs/flutter-apk/app-release.apk" + ;; + aab) + build_file="build/app/outputs/bundle/release/app-release.aab" + ;; + ios|ipa) + # `flutter build ipa` outputs a single .ipa here. + build_file=$(ls build/ios/ipa/*.ipa 2>/dev/null | head -1) + ;; + *) + print_error "Unsupported distribute platform: $platform" + echo "Supported: android | aab | ios" + exit 1 + ;; + esac + fi + + if [ -z "$build_file" ] || [ ! -f "$build_file" ]; then + print_error "Build artifact not found for '$platform': ${build_file:-}" + print_info "Build it first, e.g.: ./demo.sh build $platform" + exit 1 + fi + + print_header "Uploading to Build Distribution" + # `sentry-cli build upload` powers both Build Distribution and size analysis, + # so we reuse the existing uploader (includes git/VCS metadata). + upload_size_analysis "$build_file" "$platform" + print_info "View: https://sentry.io/organizations/$SENTRY_ORG/projects/$SENTRY_PROJECT/build-distribution/" +} + # ============================================================================ # VERIFY FUNCTION # ============================================================================ @@ -887,6 +937,7 @@ COMMANDS: build [build-type] Build app with release management run Run app and create deploy upload-size Upload size analysis to Sentry + distribute [file] Upload a build to Sentry Build Distribution verify Verify setup configuration help Show this help message @@ -992,6 +1043,17 @@ main() { upload_size_analysis "$2" "$3" ;; + distribute) + if [ -z "$2" ]; then + print_error "Platform required" + echo "Usage: ./demo.sh distribute [file]" + echo "Example: ./demo.sh distribute android" + exit 1 + fi + load_env + distribute_build "$2" "$3" + ;; + verify) verify_setup ;; @@ -1009,6 +1071,7 @@ main() { echo " build - Build app with release management" echo " run - Run app and create deploy" echo " upload-size - Upload size analysis" + echo " distribute - Upload build to Sentry Build Distribution" echo " verify - Verify setup" echo " help - Show detailed help" echo "" diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 7c56964..391a902 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 12.0 diff --git a/ios/Podfile b/ios/Podfile index e549ee2..620e46e 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 03c7b11..93c0de4 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2,16 +2,23 @@ PODS: - Flutter (1.0.0) - package_info_plus (0.4.5): - Flutter - - Sentry/HybridSDK (8.52.1) - - sentry_flutter (9.1.0): + - Sentry/HybridSDK (8.58.3) + - sentry_flutter (9.22.0): + - Flutter + - FlutterMacOS + - Sentry/HybridSDK (= 8.58.3) + - url_launcher_ios (0.0.1): + - Flutter + - webview_flutter_wkwebview (0.0.1): - Flutter - FlutterMacOS - - Sentry/HybridSDK (= 8.52.1) DEPENDENCIES: - Flutter (from `Flutter`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) SPEC REPOS: trunk: @@ -24,13 +31,19 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" sentry_flutter: :path: ".symlinks/plugins/sentry_flutter/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + webview_flutter_wkwebview: + :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - Sentry: 2cbbe3592f30050c60e916c63c7f5a2fa584005e - sentry_flutter: ad45192ff0e6b0b50e53cdaf66e9301a4d6bbb2f + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + Sentry: 108fdbb76299c4189af12246bf0308c09c278922 + sentry_flutter: fbb8a76a5a009ce7ba7bde295ee6b50b11916851 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d -PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 104cc9e..ce46244 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -455,7 +455,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -584,7 +584,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -635,7 +635,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7577ce3..c66f95b 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/lib/backend_config.dart b/lib/backend_config.dart new file mode 100644 index 0000000..a1c6253 --- /dev/null +++ b/lib/backend_config.dart @@ -0,0 +1,20 @@ +/// Selects which Empower Plant backend the app's API calls target. +/// +/// The standard journey uses the regular Flask backend. The "OTLP" journey +/// (started from the drawer) points the SAME screens at the OpenTelemetry- +/// instrumented backend instead, so the Flutter-initiated trace continues into +/// it via the propagated `sentry-trace`/`baggage` headers. +class BackendConfig { + BackendConfig._(); + + static const String standardBase = 'https://flask.empower-plant.com'; + static const String otlpBase = 'https://flask-otlp.empower-plant.com'; + + /// The currently active backend base URL. Switched per-journey (with + /// save/restore) by the screen that owns the journey — see [HomePage]. + static String base = standardBase; + + /// Endpoints, resolved against the currently active [base]. + static String get products => '$base/products'; + static String get checkout => '$base/checkout'; +} diff --git a/lib/cart.dart b/lib/cart.dart index e55b19e..109881a 100644 --- a/lib/cart.dart +++ b/lib/cart.dart @@ -3,18 +3,9 @@ import 'package:provider/provider.dart'; import 'models/cart_state_model.dart'; import 'checkout.dart'; -/// Masks a dollar amount for session replay privacy. -/// Keeps only the first digit and replaces the rest with X. -/// e.g. "12.99" → "$1XX.XX", "149.00" → "$1XX.XX" -String _maskPrice(String price) { - // Strip leading '$' if present - final raw = price.startsWith('\$') ? price.substring(1) : price; - if (raw.isEmpty) return '\$$price'; - final firstDigit = raw[0]; - // Replace every digit after the first with 'X', preserve '.' separators - final masked = raw.substring(1).replaceAll(RegExp(r'\d'), 'X'); - return '\$$firstDigit$masked'; -} +// Note: prices are shown in full in the UI. They are masked only inside Sentry +// Session Replay via the `maskCallback` in sentry_setup.dart (which masks any +// "$…" Text), so real users see real prices but replays keep them private. class CartView extends StatefulWidget { const CartView({super.key}); @@ -47,7 +38,7 @@ class _CartViewState extends State { ), SizedBox(width: 8), Text( - _maskPrice(cart.computeSubtotal().toStringAsFixed(2)), + '\$${cart.computeSubtotal().toStringAsFixed(2)}', style: TextStyle( fontSize: 17, fontWeight: FontWeight.bold, @@ -135,7 +126,7 @@ class _CartViewState extends State { Text(cartItem.id.toString()), Padding(padding: EdgeInsets.fromLTRB(0, 5, 0, 5)), Text( - _maskPrice(cartItem.price.toString()), + '\$${cartItem.price.toStringAsFixed(2)}', style: TextStyle(color: Colors.red[900], fontSize: 17), ), ], diff --git a/lib/checkout.dart b/lib/checkout.dart index 0ef2862..4199c28 100644 --- a/lib/checkout.dart +++ b/lib/checkout.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:logging/logging.dart'; +import 'backend_config.dart'; import 'sentry_setup.dart'; import 'se_config.dart'; @@ -20,7 +21,6 @@ class CheckoutView extends StatefulWidget { } class _CheckoutViewState extends State { - final _uri = "https://flask.empower-plant.com/checkout"; final _promoCodeController = TextEditingController(text: 'SAVE20'); final _log = Logger('CheckoutLogger'); String? _promoErrorMessage; @@ -141,7 +141,8 @@ class _CheckoutViewState extends State { try { final checkoutResult = await client.post( - Uri.parse(_uri), + // Resolve at request time so the OTLP journey posts to flask-otlp. + Uri.parse(BackendConfig.checkout), body: jsonEncode({ "email": getRandomEmail(), "cart": { diff --git a/lib/main.dart b/lib/main.dart index 1876b68..072206d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,7 +10,8 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'sentry_setup.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:logging/logging.dart'; -import 'dart:io'; +import 'backend_config.dart'; +import 'platform/platform_info.dart'; final GlobalKey navigatorKey = GlobalKey(); final log = Logger('EmpowerPlantLogger'); @@ -23,9 +24,9 @@ Future main() async { Sentry.configureScope((scope) { scope.setTag('app.name', 'Empower Plant'); scope.setTag('platform', 'flutter'); - scope.setTag('dart.version', Platform.version); - scope.setTag('os', Platform.operatingSystem); - scope.setTag('os.version', Platform.operatingSystemVersion); + scope.setTag('dart.version', nativeDartVersion); + scope.setTag('os', nativeOsName); + scope.setTag('os.version', nativeOsVersion); // Get platform dispatcher for locale and screen size (replaces deprecated window API) final view = WidgetsBinding.instance.platformDispatcher.views.first; @@ -70,6 +71,10 @@ class MyApp extends StatelessWidget { enableAutoTransactions: true, // Auto-finish transactions after 3 seconds (default) autoFinishAfter: const Duration(seconds: 3), + // Start a fresh trace on each named-route navigation so each user + // journey (e.g. the Web View) is its own transaction + trace, + // instead of the whole session sharing one trace id. + enableNewTraceOnNavigation: true, ), ], routes: { @@ -88,7 +93,15 @@ class MyApp extends StatelessWidget { } class HomePage extends StatefulWidget { - const HomePage({super.key}); + /// Route name used when pushing the OTLP variant so SentryNavigatorObserver + /// starts a fresh trace for the journey. + static const String otlpRouteName = 'otlp/home'; + + /// When set, this HomePage instance targets the given backend base URL for + /// the duration it is on screen (used by the OTLP journey). Null = default. + final String? backendBase; + + const HomePage({super.key, this.backendBase}); @override // ignore: library_private_types_in_public_api @@ -123,9 +136,19 @@ class _HomePageState extends State { Destination.withChild(Icons.shopping_bag, "Cart", CartView()), ]; + // Backend base URL active before this HomePage took over (for save/restore + // so nested journeys, e.g. OTLP opened from within OTLP, behave correctly). + String? _previousBackendBase; + @override void initState() { super.initState(); + // If this HomePage targets a specific backend (OTLP journey), activate it + // for the lifetime of this screen, restoring the previous one on dispose. + if (widget.backendBase != null) { + _previousBackendBase = BackendConfig.base; + BackendConfig.base = widget.backendBase!; + } // Intentionally perform a long-running regex operation on the main thread to trigger Sentry performance issue try { final largeText = List.generate( @@ -178,6 +201,15 @@ class _HomePageState extends State { } } + @override + void dispose() { + // Restore the previous backend base when leaving an OTLP journey. + if (widget.backendBase != null && _previousBackendBase != null) { + BackendConfig.base = _previousBackendBase!; + } + super.dispose(); + } + @override Widget build(BuildContext context) { log.info('Building HomePage with index: ���_currentIndex'); diff --git a/lib/navbar_destination.dart b/lib/navbar_destination.dart index b5f1ac1..e487b24 100644 --- a/lib/navbar_destination.dart +++ b/lib/navbar_destination.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; import 'package:flutter/services.dart'; // ignore: depend_on_referenced_packages import 'package:sentry/sentry.dart'; -import 'package:sentry_file/sentry_file.dart'; +import 'backend_config.dart'; +import 'main.dart'; +import 'platform/platform_info.dart'; import 'sentry_setup.dart'; +import 'webview/web_view_screen.dart'; class Destination { IconData icon; @@ -31,7 +32,21 @@ class _DestinationViewState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text(widget.destination.title)), + appBar: AppBar( + title: Text(widget.destination.title), + // Custom, keyed drawer button so Sentry's user-interaction span reads + // `ui.action.click - open_navigation_menu` instead of the cryptic + // built-in `StandardComponentType.drawerButton`. The tooltip preserves + // the "Open navigation menu" accessibility label. + leading: Builder( + builder: (context) => IconButton( + key: const ValueKey('open_navigation_menu'), + icon: const Icon(Icons.menu), + tooltip: 'Open navigation menu', + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ), + ), drawer: Drawer( child: ListView( children: ListTile.divideTiles( @@ -56,8 +71,63 @@ class _DestinationViewState extends State { await transaction.finish(); }, ), + // Web View — opens empower-plant.com with Flutter-side + // distributed tracing (sentry-trace/baggage attached to the load). + // Shown on all platforms (in-app webview on mobile, iframe on web, + // system browser on desktop). + ListTile( + title: const Text('Web View'), + onTap: () { + // The Web View journey gets its own transaction + trace, + // started inside WebViewScreen after navigation settles + // (so it doesn't inherit the current screen's trace). + Sentry.addBreadcrumb(Breadcrumb( + category: 'ui.action', + message: 'Opened Web View', + level: SentryLevel.info, + data: {'url': kWebViewUrl}, + )); + Navigator.pop(context); + // No named route here: WebViewScreen owns its own trace + + // transaction (bound to scope) so the trace handed to the + // loaded page is unambiguously the webview transaction's. + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const WebViewScreen( + url: kWebViewUrl, + title: 'Empower Plant (Web)', + ), + ), + ); + }, + ), + // OTLP — opens the home page but routes all backend calls + // (/products, /checkout) to the OpenTelemetry-instrumented + // backend (flask-otlp), as its own new trace/journey. + ListTile( + title: const Text('OTLP'), + onTap: () { + Sentry.addBreadcrumb(Breadcrumb( + category: 'ui.action', + message: 'Opened OTLP backend journey', + level: SentryLevel.info, + data: {'backend': BackendConfig.otlpBase}, + )); + Navigator.pop(context); + Navigator.of(context).push( + MaterialPageRoute( + // Named route → new trace for the OTLP journey. + settings: const RouteSettings( + name: HomePage.otlpRouteName, + ), + builder: (_) => + const HomePage(backendBase: BackendConfig.otlpBase), + ), + ); + }, + ), // Platform-specific: ANR for Android, App Hang for iOS/macOS - if (Platform.isAndroid) + if (isAndroid) ListTile( title: Text('ANR (Android)'), onTap: () async { @@ -79,7 +149,7 @@ class _DestinationViewState extends State { await transaction.finish(); }, ), - if (Platform.isIOS || Platform.isMacOS) + if (isIOS || isMacOS) ListTile( title: Text('App Hang (iOS/macOS)'), onTap: () async { diff --git a/lib/platform/file_io_demo.dart b/lib/platform/file_io_demo.dart new file mode 100644 index 0000000..91a06eb --- /dev/null +++ b/lib/platform/file_io_demo.dart @@ -0,0 +1,6 @@ +// Cross-platform entry point for the file-I/O demo (main-thread blocking). +// +// On mobile/desktop this performs real instrumented `dart:io` file I/O via +// `sentry_file`. On web (no `dart:io`) it falls back to equivalent heavy +// main-thread work so the performance demo still triggers. +export 'file_io_demo_io.dart' if (dart.library.js_interop) 'file_io_demo_web.dart'; diff --git a/lib/platform/file_io_demo_io.dart b/lib/platform/file_io_demo_io.dart new file mode 100644 index 0000000..2c5d211 --- /dev/null +++ b/lib/platform/file_io_demo_io.dart @@ -0,0 +1,43 @@ +// Native file-I/O demo (mobile/desktop). Uses Sentry's file instrumentation. +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_file/sentry_file.dart'; + +/// Writes and reads a large file synchronously on the main thread to trigger +/// the "File I/O on Main Thread" performance issue. Returns the byte length. +int performFileIODemo() { + final tempDir = Directory.systemTemp; + final file = File('${tempDir.path}/plant_cache.txt').sentryTrace(); + file.writeAsStringSync(List.filled(500000, 'Plant data ').join()); + final content = file.readAsStringSync(); + return content.length; +} + +/// Standalone instrumented file-operations transaction example. +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 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(); + } +} diff --git a/lib/platform/file_io_demo_web.dart b/lib/platform/file_io_demo_web.dart new file mode 100644 index 0000000..d3f4a05 --- /dev/null +++ b/lib/platform/file_io_demo_web.dart @@ -0,0 +1,15 @@ +// Web fallback for the file-I/O demo (no dart:io on web). +// +// Browsers have no synchronous filesystem, so we reproduce the equivalent +// heavy main-thread work to still surface a performance issue in Sentry. + +/// Builds a large string on the main thread (mirrors the native file write/read +/// size) so the "main thread blocking" performance demo still triggers on web. +int performFileIODemo() { + final content = List.filled(500000, 'Plant data ').join(); + // Touch the data so the work isn't optimized away. + return content.length; +} + +/// No-op on web (the standalone instrumented file example is native-only). +Future sentryFileExample() async {} diff --git a/lib/platform/native_info_io.dart b/lib/platform/native_info_io.dart new file mode 100644 index 0000000..b0ad0bc --- /dev/null +++ b/lib/platform/native_info_io.dart @@ -0,0 +1,6 @@ +// Native (dart:io) platform strings. Used on mobile/desktop builds. +import 'dart:io' as io; + +String get nativeDartVersion => io.Platform.version; +String get nativeOsName => io.Platform.operatingSystem; +String get nativeOsVersion => io.Platform.operatingSystemVersion; diff --git a/lib/platform/native_info_web.dart b/lib/platform/native_info_web.dart new file mode 100644 index 0000000..73c6bb8 --- /dev/null +++ b/lib/platform/native_info_web.dart @@ -0,0 +1,6 @@ +// Web fallback for native platform strings (no dart:io on web). +import 'package:web/web.dart' as web; + +String get nativeDartVersion => 'dart-web'; +String get nativeOsName => 'web'; +String get nativeOsVersion => web.window.navigator.userAgent; diff --git a/lib/platform/platform_info.dart b/lib/platform/platform_info.dart new file mode 100644 index 0000000..125f83c --- /dev/null +++ b/lib/platform/platform_info.dart @@ -0,0 +1,25 @@ +/// Web-safe platform detection. +/// +/// Uses `kIsWeb` + `defaultTargetPlatform` from `package:flutter/foundation.dart` +/// so it works on every target (including web) without importing `dart:io`. +/// Native-only string info (Dart/OS version) comes from a conditional import +/// so web builds never reference `dart:io`. +library; + +import 'package:flutter/foundation.dart'; + +export 'native_info_io.dart' if (dart.library.js_interop) 'native_info_web.dart'; + +bool get isWeb => kIsWeb; +bool get isAndroid => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; +bool get isIOS => !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; +bool get isMacOS => !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS; +bool get isDesktop => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.linux); + +/// Human-readable platform name, safe on web ("web"). +String get platformName => kIsWeb ? 'web' : defaultTargetPlatform.name; diff --git a/lib/product_list.dart b/lib/product_list.dart index 06d8b02..0c2e58a 100644 --- a/lib/product_list.dart +++ b/lib/product_list.dart @@ -8,8 +8,10 @@ import 'package:provider/provider.dart'; import 'models/cart_state_model.dart'; import 'product_details.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_file/sentry_file.dart'; -import 'dart:io'; +import 'backend_config.dart'; +import 'platform/platform_info.dart'; +import 'platform/file_io_demo.dart'; +import 'sentry_setup.dart'; import 'se_config.dart'; // ignore: depend_on_referenced_packages @@ -22,7 +24,6 @@ class ItemsList extends StatefulWidget { } class _ItemListState extends State { - final String _uri = 'https://flask.empower-plant.com/products'; late Future shopItems; var client = SentryHttpClient(); @@ -30,7 +31,9 @@ class _ItemListState extends State { Future fetchShopItems() async { try { - final response = await client.get(Uri.parse(_uri)); + // Resolve the products endpoint at request time so the OTLP journey + // (which switches BackendConfig.base) targets flask-otlp instead. + final response = await client.get(Uri.parse(BackendConfig.products)); // Simulate full response processing final data = ResponseData.fromJson((jsonDecode(response.body))); return data; @@ -43,8 +46,23 @@ class _ItemListState extends State { void initState() { super.initState(); + // Vary the user cohort per session (randomized email) while keeping the + // engineer + customer segment context so events stay attributable. final email = getRandomEmail(); - Sentry.configureScope((scope) => scope.setUser(SentryUser(id: email))); + Sentry.configureScope((scope) { + scope.setUser(SentryUser( + id: email, + username: se, + email: email, + data: {'customerType': sessionCustomerType, 'segment': sessionCustomerType}, + )); + }); + Sentry.addBreadcrumb(Breadcrumb( + category: 'navigation', + message: 'Opened product catalog', + level: SentryLevel.info, + data: {'screen': 'product_list', 'customerType': sessionCustomerType}, + )); // PERFORMANCE ISSUES (triggered synchronously on main thread) // 1. Simulate Database Query on Main Thread (triggers DB on Main Thread issue) @@ -107,20 +125,14 @@ class _ItemListState extends State { } } - // Simulate slow file I/O on main thread + // Simulate slow file I/O on main thread (native: real dart:io file via + // sentry_file; web: equivalent heavy main-thread work — see platform/file_io_demo). void _performFileIO() { try { - // Use system temp directory for cross-platform compatibility - final tempDir = Directory.systemTemp; - final file = File('${tempDir.path}/plant_cache.txt').sentryTrace(); - - // Write large file synchronously on main thread (>16ms) - file.writeAsStringSync(List.filled(500000, 'Plant data ').join()); - - final content = file.readAsStringSync(); + final size = performFileIODemo(); if (kDebugMode) { - print('File I/O on main thread, size: ${content.length}'); + print('File I/O on main thread, size: $size'); } } catch (e) { if (kDebugMode) { @@ -180,7 +192,7 @@ class _ItemListState extends State { // Trigger Kotlin Exception on startup (Android only) Future _triggerKotlinException() async { - if (!Platform.isAndroid) return; + if (!isAndroid) return; final transaction = Sentry.startTransaction( 'startup.kotlin_exception', @@ -526,12 +538,6 @@ class _ItemListState extends State { @override Widget build(BuildContext context) { - var size = MediaQuery.of(context).size; - - /*24 is for notification bar on Android*/ - final double itemHeight = (size.height - kToolbarHeight - 24) / 2; - final double itemWidth = size.width * 1.3; - return FutureBuilder( future: shopItems, builder: (context, snapshot) { @@ -540,63 +546,73 @@ class _ItemListState extends State { throw Exception("Error fetching shop data"); } return SingleChildScrollView( - child: Column( - children: [ - Container( - alignment: Alignment.centerLeft, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.fromLTRB(20, 30, 0, 10), - child: Text( - "Empower your plants", - style: TextStyle( - fontSize: 35.0, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - textAlign: TextAlign.center, - ), - ), - Padding( - padding: EdgeInsets.fromLTRB(20, 10, 0, 0), - child: SizedBox( - width: double.infinity, + // Cap the catalog width and center it so wide web/desktop windows + // don't stretch the 2-column grid into giant tiles (which pushed + // the products far down the page). Mobile is unaffected since its + // width is already below this cap. + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Column( + children: [ + Container( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(20, 30, 0, 10), child: Text( - "Keep your houseplants happy 🪴", + "Empower your plants", style: TextStyle( - fontSize: 20.0, + fontSize: 35.0, + fontWeight: FontWeight.bold, color: Colors.black, ), textAlign: TextAlign.center, ), ), - ), - ], + Padding( + padding: EdgeInsets.fromLTRB(20, 10, 0, 0), + child: SizedBox( + width: double.infinity, + child: Text( + "Keep your houseplants happy 🪴", + style: TextStyle( + fontSize: 20.0, + color: Colors.black, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), ), - ), - GridView.count( - physics: NeverScrollableScrollPhysics(), - primary: false, - shrinkWrap: true, - childAspectRatio: (itemHeight / itemWidth), - padding: const EdgeInsets.all(20.0), - crossAxisSpacing: 10.0, - mainAxisSpacing: 20.0, - crossAxisCount: 2, - children: - (snapshot.data?.items.take(4).map((shopItem) { - return _buildRow( - ResponseItem.fromJson(shopItem), - shopItem, - ); - }).toList() ?? - []), - ), - ], + GridView.count( + physics: NeverScrollableScrollPhysics(), + primary: false, + shrinkWrap: true, + // Stable portrait card ratio across all platforms. + childAspectRatio: 0.72, + padding: const EdgeInsets.all(20.0), + crossAxisSpacing: 10.0, + mainAxisSpacing: 20.0, + crossAxisCount: 2, + children: + (snapshot.data?.items.take(4).map((shopItem) { + return _buildRow( + ResponseItem.fromJson(shopItem), + shopItem, + ); + }).toList() ?? + []), + ), + ], + ), ), - ); + ), + ); } else { return CircularProgressIndicator(); } diff --git a/lib/se_config.dart b/lib/se_config.dart index 12e38bd..86b0225 100644 --- a/lib/se_config.dart +++ b/lib/se_config.dart @@ -1,3 +1,4 @@ -// Each engineer should set their name or identifier here. -// This tags all Sentry events with your identifier for separation. -const String se = 'kunal'; // <-- Change this to your name or ID +// Default SE identifier for the demo. Tags all Sentry events for separation. +// Keep this as 'tda' in the repo — do NOT commit your own name. If you need to +// separate your own events locally, change it locally but don't push that change. +const String se = 'tda'; diff --git a/lib/sentry_setup.dart b/lib/sentry_setup.dart index 4e28b94..5f92965 100644 --- a/lib/sentry_setup.dart +++ b/lib/sentry_setup.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -7,13 +6,20 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:dio/dio.dart'; import 'package:sentry_dio/sentry_dio.dart'; -import 'package:sentry_file/sentry_file.dart'; import 'package:sentry_logging/sentry_logging.dart'; import 'package:logging/logging.dart'; +import 'platform/platform_info.dart'; import 'se_config.dart'; +// Re-export the web-safe file-I/O demo helpers (native uses dart:io + sentry_file). +export 'platform/file_io_demo.dart' show sentryFileExample; + final GlobalKey navigatorKey = GlobalKey(); +/// The customer type chosen for this app session (set during [initSentry]). +/// Reused across the app so user/context enrichment stays consistent. +String sessionCustomerType = 'enterprise'; + /// Generates a random customer type based on specified distribution /// - 40% enterprise /// - 20% small-plan @@ -69,7 +75,7 @@ Future initSentry({required VoidCallback appRunner}) async { options.environment = sentryEnvironment; // Set distribution to match build number for better symbol matching - options.dist = '1'; // Matches version 9.14.0+1 + options.dist = '1'; // Matches version 9.22.0+1 // Debug settings (disabled for production to ensure proper symbol resolution) // Set to true only when debugging Sentry configuration issues @@ -143,7 +149,7 @@ Future initSentry({required VoidCallback appRunner}) async { options.enableWatchdogTerminationTracking = true; // Configure App Hang tracking for iOS/macOS - if (Platform.isIOS || Platform.isMacOS) { + if (isIOS || isMacOS) { // App hang timeout interval (default is 2 seconds) options.appHangTimeoutInterval = const Duration(seconds: 2); // Note: App Hang Tracking V2 is enabled by default in SDK 9.0.0+ @@ -163,6 +169,20 @@ Future initSentry({required VoidCallback appRunner}) async { options.captureFailedRequests = true; options.maxRequestBodySize = MaxRequestBodySize.medium; + // Distributed tracing: attach `sentry-trace` + `baggage` headers to + // outgoing requests to the Empower Plant backends (and localhost for dev) + // so spans link across services. SentryHttpClient/SentryDio honor this. + options.tracePropagationTargets + ..clear() + ..addAll([ + 'empower-plant.com', + 'flask.empower-plant.com', + // OTLP-instrumented backend — propagate the trace so it continues + // into the flask-otlp service (distributed tracing into OpenTelemetry). + 'flask-otlp.empower-plant.com', + 'localhost', + ]); + // ======================================== // Logging Integration // ======================================== @@ -228,13 +248,49 @@ Future initSentry({required VoidCallback appRunner}) async { }, appRunner: appRunner); // ======================================== - // Customer Type Tag Configuration + // Customer Type & Demo Context Configuration // ======================================== // Set a random customer type tag for all events to demonstrate tag filtering // Distribution: 40% enterprise, 20% small-plan, 20% medium-plan, 20% large-plan final customerType = getRandomCustomerType(); + sessionCustomerType = customerType; Sentry.configureScope((scope) { + // Tags for fast filtering/segmentation in the Issues/Performance views. scope.setTag('customerType', customerType); + scope.setTag('app.flavor', 'demo'); + scope.setTag('app.platform', platformName); + scope.setTag('se', se); + + // 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( + id: se, + username: se, + email: '$se@empower-plant.com', + data: {'customerType': customerType, 'segment': customerType}, + )); + + // Custom contexts — show up as their own cards on the event detail page. + scope.setContexts('demo', { + 'engineer': se, + 'customer_type': customerType, + 'sdk': 'sentry_flutter', + 'features': const [ + 'errors', + 'tracing', + 'profiling', + 'session_replay', + 'logs', + 'metrics', + 'distributed_tracing', + ], + }); + scope.setContexts('release_info', { + 'release': sentryRelease ?? 'unknown', + 'environment': sentryEnvironment ?? 'unknown', + 'dist': '1', + }); + if (kDebugMode) { print('Sentry: Set customerType tag to: $customerType'); } @@ -299,34 +355,8 @@ void showUserFeedbackDialog(BuildContext context, SentryId eventId) async { } } -// Sentry file I/O instrumentation example -// Use this to automatically track file operations performance -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 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(); - } -} +// `sentryFileExample()` (instrumented file-I/O transaction) lives in +// platform/file_io_demo_io.dart and is re-exported above (web build gets a no-op). // Example logger usage final log = Logger('EmpowerPlantLogger'); diff --git a/lib/webview/trace_headers.dart b/lib/webview/trace_headers.dart new file mode 100644 index 0000000..91f43e1 --- /dev/null +++ b/lib/webview/trace_headers.dart @@ -0,0 +1,33 @@ +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. +/// +/// 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() { + final headers = {}; + final span = Sentry.getSpan(); + if (span == null) return headers; + + final trace = span.toSentryTrace(); + 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(); + if (headers.isEmpty) return uri; + return uri.replace(queryParameters: { + ...uri.queryParameters, + for (final entry in headers.entries) entry.key: entry.value, + }); +} diff --git a/lib/webview/web_view_io.dart b/lib/webview/web_view_io.dart new file mode 100644 index 0000000..2b9285d --- /dev/null +++ b/lib/webview/web_view_io.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import '../platform/platform_info.dart'; +import 'trace_headers.dart'; + +/// Native body builder. +/// - Android/iOS: in-app [WebViewWidget]; the active trace is passed as URL +/// query params (sentry-trace/baggage) so the loaded page's JS Sentry SDK can +/// continue the trace (request headers aren't readable by browser JS). +/// - Desktop (macOS/Windows/Linux): webview_flutter has no implementation, so +/// open the system browser via url_launcher (trace passed as query params). +Widget buildWebViewBody( + BuildContext context, + String url, { + VoidCallback? onPageFinished, +}) { + if (isAndroid || isIOS) { + return _MobileWebView(url: url, onPageFinished: onPageFinished); + } + return _DesktopLauncher(url: url, onPageFinished: onPageFinished); +} + +class _MobileWebView extends StatefulWidget { + final String url; + final VoidCallback? onPageFinished; + const _MobileWebView({required this.url, this.onPageFinished}); + + @override + State<_MobileWebView> createState() => _MobileWebViewState(); +} + +class _MobileWebViewState extends State<_MobileWebView> { + late final WebViewController _controller; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + // Notify when the page finishes loading so the owning screen can finish + // (and send) the Flutter webview transaction. + ..setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => widget.onPageFinished?.call(), + ), + ) + // 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))); + } + + @override + Widget build(BuildContext context) => WebViewWidget(controller: _controller); +} + +class _DesktopLauncher extends StatelessWidget { + final String url; + final VoidCallback? onPageFinished; + const _DesktopLauncher({required this.url, this.onPageFinished}); + + Future _open() async { + final uri = withTraceQueryParams(Uri.parse(url)); + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + @override + Widget build(BuildContext context) { + // Auto-open once, and offer a button to reopen. The page loads in an + // external browser we can't observe, so signal "finished" right after launch. + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _open(); + onPageFinished?.call(); + }); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.public, size: 48), + const SizedBox(height: 12), + const Text('Opening Empower Plant in your browser…'), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _open, + icon: const Icon(Icons.open_in_browser), + label: const Text('Open again'), + ), + ], + ), + ); + } +} diff --git a/lib/webview/web_view_screen.dart b/lib/webview/web_view_screen.dart new file mode 100644 index 0000000..6319c19 --- /dev/null +++ b/lib/webview/web_view_screen.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +// Picks the platform implementation: in-app webview on mobile, system browser +// on desktop (web_view_io.dart), or a dart:ui_web iframe on Flutter web. +import 'web_view_io.dart' if (dart.library.js_interop) 'web_view_web.dart' + as impl; + +/// The Web View target — the Empower Plant React app's products page. +/// +/// We open `/products` (not `/`) so the products page is the React *pageload*, +/// which continues the Flutter-initiated trace. (Only the pageload inherits the +/// handed-off trace; subsequent in-app navigations start their own traces.) +/// For local testing, point this at the React dev server instead, e.g. +/// `http://10.0.2.2:3000/products?backend=flask` (Android emulator → host). +const String kWebViewUrl = 'https://empower-plant.com/products'; + +/// Opens [url] in a webview as its own transaction on a fresh trace, and hands +/// that trace off to the loaded page (Flutter → React distributed tracing). +class WebViewScreen extends StatefulWidget { + static const String transactionName = 'webview/empower-plant'; + + final String url; + final String title; + + const WebViewScreen({ + super.key, + required this.url, + this.title = 'Web View', + }); + + @override + State createState() => _WebViewScreenState(); +} + +class _WebViewScreenState extends State { + ISentrySpan? _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. + // ignore: invalid_use_of_internal_member + Sentry.currentHub.generateNewTrace(); + final tx = Sentry.startTransaction( + WebViewScreen.transactionName, + 'navigation', + bindToScope: true, + ); + tx.setData('url', widget.url); + _transaction = tx; + } + + // Finish (and send) the transaction once — when the page loads, or on dispose + // 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()); + } + + @override + void dispose() { + _finishTransaction(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.title)), + body: impl.buildWebViewBody( + context, + widget.url, + // 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, + ), + ); + } +} diff --git a/lib/webview/web_view_web.dart b/lib/webview/web_view_web.dart new file mode 100644 index 0000000..3025fcc --- /dev/null +++ b/lib/webview/web_view_web.dart @@ -0,0 +1,56 @@ +import 'dart:ui_web' as ui_web; + +import 'package:flutter/material.dart'; +import 'package:web/web.dart' as web; + +import 'trace_headers.dart'; + +// Monotonic id so each iframe gets a unique platform-view type. +int _viewCounter = 0; + +/// Flutter-web body builder: embeds [url] in an `