From fb40263926a1acdb2738a6fa1216e926972d17d3 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 1 Sep 2026 13:21:26 +0200 Subject: [PATCH 1/2] Enable standalone app start tracing with stream-mode extension spans Opt in to enableStandaloneAppStartTracing (SDK 9.28) so app start is its own app.start root, and extend it past the first frame around feature-flag hydration and the initial catalog fetch. Co-authored-by: Cursor --- .env.example | 2 +- lib/app_start.dart | 100 ++++++++++++++++++++++++++++++++++++++++++ lib/main.dart | 4 ++ lib/product_list.dart | 18 +++++--- lib/sentry_setup.dart | 13 +++++- pubspec.lock | 36 +++++++-------- pubspec.yaml | 10 ++--- 7 files changed, 151 insertions(+), 32 deletions(-) create mode 100644 lib/app_start.dart diff --git a/.env.example b/.env.example index bef2bf6..d493cb4 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.25.0+1 # Format: @version+build (matches the build identifier used by Build Distribution) +SENTRY_RELEASE=com.example.empower_flutter@9.28.0+1 # Format: @version+build (matches the build identifier used by Build Distribution) SENTRY_ENVIRONMENT=your_environment # ======================================== diff --git a/lib/app_start.dart b/lib/app_start.dart new file mode 100644 index 0000000..944c1d3 --- /dev/null +++ b/lib/app_start.dart @@ -0,0 +1,100 @@ +// ignore_for_file: experimental_member_use + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +/// Coordinates standalone app-start extension for this demo. +/// +/// Call [begin] from `appRunner` *before* `runApp` so `extendAppStart` +/// lands before the first frame. Custom children are parented with +/// `SentryFlutter.getExtendedAppStartSpanV2()` because the demo uses stream +/// lifecycle — `getExtendedAppStartSpan()` is the static-lifecycle getter and +/// returns null here. +/// +/// The extension stays open through feature-flag hydration and the first +/// catalog fetch, then `finishExtendedAppStart()` releases it. If the catalog +/// never arrives, we still finish before the SDK's 30s deadline. +class AppStart { + static const _featureFlagCount = 15; + static const _catalogTimeout = Duration(seconds: 20); + + static final Completer _catalogReady = Completer(); + static Future? _extension; + + /// Must run before the first frame. Safe to call only once. + static void begin() { + if (_extension != null) { + return; + } + SentryFlutter.extendAppStart(); + _extension = _run(); + unawaited(_extension!); + } + + static Future _run() async { + try { + await span( + 'Hydrate feature flags', + _hydrateFeatureFlags, + op: 'app.init', + ); + await _catalogReady.future.timeout( + _catalogTimeout, + onTimeout: () {}, + ); + } finally { + await SentryFlutter.finishExtendedAppStart(); + } + } + + /// Runs [work] as a child of the extended app-start span when one is open. + /// + /// In stream mode the extension is not the active span, so we must pass it + /// as `parentSpan`. If the extension has already ended (or never started — + /// e.g. web/desktop), [work] runs without opening a stray root span. + static Future span( + String name, + Future Function() work, { + String op = 'app.init', + }) async { + final parent = SentryFlutter.getExtendedAppStartSpanV2(); + if (parent == null) { + return work(); + } + return Sentry.startSpan(name, (span) async { + span.setAttribute('sentry.op', SentryAttribute.string(op)); + return work(); + }, parentSpan: parent); + } + + /// Signals that the first catalog fetch finished (success or failure). + static void markCatalogReady() { + if (!_catalogReady.isCompleted) { + _catalogReady.complete(); + } + } + + static Future _hydrateFeatureFlags() async { + final loaded = await Future.wait([ + for (var i = 1; i <= _featureFlagCount; i++) + rootBundle.loadString('assets/config/feature_$i.json'), + ]); + var enabled = 0; + for (final raw in loaded) { + final decoded = jsonDecode(raw); + if (decoded is Map && decoded['enabled'] == true) { + enabled++; + } + } + Sentry.configureScope((scope) { + scope.setContexts('feature_flags', { + 'count': loaded.length, + 'enabled': enabled, + }); + }); + } +} + diff --git a/lib/main.dart b/lib/main.dart index 690a351..b70cdc4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:logging/logging.dart'; import 'backend_config.dart'; import 'platform/platform_info.dart'; +import 'app_start.dart'; final GlobalKey navigatorKey = GlobalKey(); final log = Logger('EmpowerPlantLogger'); @@ -39,6 +40,9 @@ Future main() async { await initSentry( appRunner: () { log.info('Running app'); + // Extend before runApp so the first frame cannot close app start. + // Custom children + finish live in AppStart (feature flags + catalog). + AppStart.begin(); runApp( DefaultAssetBundle( bundle: SentryAssetBundle(), diff --git a/lib/product_list.dart b/lib/product_list.dart index 2f72324..8a16b80 100644 --- a/lib/product_list.dart +++ b/lib/product_list.dart @@ -13,6 +13,7 @@ import 'platform/platform_info.dart'; import 'platform/file_io_demo.dart'; import 'sentry_setup.dart'; import 'se_config.dart'; +import 'app_start.dart'; // ignore: depend_on_referenced_packages class ItemsList extends StatefulWidget { @@ -31,12 +32,16 @@ class _ItemListState extends State { Future fetchShopItems() async { try { - // 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; + // Nest under the extended app-start span while it's still open so the + // first catalog fetch is part of `app.start` (stream mode requires an + // explicit parent — see AppStart.span). After the extension ends this + // is a no-op wrapper and SentryHttpClient still traces the request. + return await AppStart.span('Fetch product catalog', () async { + // 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)); + return ResponseData.fromJson((jsonDecode(response.body))); + }, op: 'http.client'); } catch (e) { rethrow; } @@ -100,6 +105,7 @@ class _ItemListState extends State { // Fetch products from API and report TTFD when complete shopItems = fetchShopItems().whenComplete(() { + AppStart.markCatalogReady(); // Report TTFD as soon as fetching the shop items is done if (mounted) { SentryDisplayWidget.of(context).reportFullyDisplayed(); diff --git a/lib/sentry_setup.dart b/lib/sentry_setup.dart index d89a234..2d7000d 100644 --- a/lib/sentry_setup.dart +++ b/lib/sentry_setup.dart @@ -75,12 +75,12 @@ 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.25.0+1") + // CRITICAL: This must be in format "appname@version+build" (e.g., "empower_flutter@9.28.0+1") options.release = sentryRelease; options.environment = sentryEnvironment; // Set distribution to match build number for better symbol matching - options.dist = '1'; // Matches version 9.25.0+1 + options.dist = '1'; // Matches version 9.28.0+1 // Debug settings (disabled for production to ensure proper symbol resolution) // Toggle on via --dart-define=SENTRY_DEBUG=true (or SENTRY_DEBUG=true in .env) @@ -104,6 +104,14 @@ Future initSentry({required VoidCallback appRunner}) async { // the legacy `Sentry.startTransaction` / `startChild` APIs are no-ops here. options.traceLifecycle = SentryTraceLifecycle.stream; + // Standalone app start (experimental since SDK 9.26.0). Reports process + // start → first frame as its own `App Start` root (`app.start`) instead of + // nesting it under the synthetic initial `ui.load`. Android/iOS only — + // other platforms keep the ui.load-attached behavior. Extended past the + // first frame in `lib/app_start.dart` via extendAppStart / finishExtendedAppStart. + // ignore: experimental_member_use + options.enableStandaloneAppStartTracing = true; + // Enable profiling (relative to tracesSampleRate) // Required for JSON Decoding, Image Decoding, and Frame Drop detection // Profiling is available on iOS, macOS, and Android @@ -308,6 +316,7 @@ Future initSentry({required VoidCallback appRunner}) async { 'logs', 'metrics', 'distributed_tracing', + 'standalone_app_start', ], }); scope.setContexts('release_info', { diff --git a/pubspec.lock b/pubspec.lock index bdf3a3a..277710c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -260,10 +260,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -276,10 +276,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -372,10 +372,10 @@ packages: dependency: transitive description: name: sentry - sha256: a84bf3a83b3ce1c89fce28b9f97659e3826df14a73a256952465aac2c5efdf5a + sha256: "5641a2acec1647f6482e472679374ac02070464e8c1d0df946361393ea7681a2" url: "https://pub.dev" source: hosted - version: "9.25.0" + version: "9.28.0" sentry_dart_plugin: dependency: "direct dev" description: @@ -388,34 +388,34 @@ packages: dependency: "direct main" description: name: sentry_dio - sha256: "297caacf2ed037d859611d94165f35c34cf0462420aea1ceb1fed8c87a06f1dd" + sha256: "8d06e18924922e518b7a318c6c22e1069cd356428fd92c7b113f4eb0ea0d7d82" url: "https://pub.dev" source: hosted - version: "9.25.0" + version: "9.28.0" sentry_file: dependency: "direct main" description: name: sentry_file - sha256: "0e4a36c02901855a383972b210ec33fc97a2619d9e0a4f7562538ed2c8f735f1" + sha256: "5f5c1e7748ac0357241cfddd794b24ca9d718ea5e7ae26a080300dd72810dbbd" url: "https://pub.dev" source: hosted - version: "9.25.0" + version: "9.28.0" sentry_flutter: dependency: "direct main" description: name: sentry_flutter - sha256: c85575266d91f57364e9b4cb522835156ec3c5581c78339dc7fae491cd0c6f76 + sha256: "41df8ab3a30190a05eb44e2c0e739b5ba90eb341de771454ced282295fde8c62" url: "https://pub.dev" source: hosted - version: "9.25.0" + version: "9.28.0" sentry_logging: dependency: "direct main" description: name: sentry_logging - sha256: "7506853de2fe61dd1d814b5266b24360635cd7fc7076c89ea3676f4b4ce2b948" + sha256: "189bfbee55ba4dcb1d68bf32dc8bbd25b2cc2732efa40b801ce5ec36cd18c0c4" url: "https://pub.dev" source: hosted - version: "9.25.0" + version: "9.28.0" sky_engine: dependency: transitive description: flutter @@ -473,10 +473,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" typed_data: dependency: transitive description: @@ -561,10 +561,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 710f0e6..80b7903 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.25.0+1 +version: 9.28.0+1 environment: sdk: ">=3.5.0 <4.0.0" @@ -36,12 +36,12 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.9 provider: ^6.1.5 - sentry_flutter: ^9.25.0 + sentry_flutter: ^9.28.0 flutter_dotenv: ^6.0.1 - sentry_dio: ^9.25.0 + sentry_dio: ^9.28.0 dio: ^5.10.0 - sentry_file: ^9.25.0 - sentry_logging: ^9.25.0 + sentry_file: ^9.28.0 + sentry_logging: ^9.28.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) From dd1a4d65133259b36577ff4eedcda03b500234ab Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 1 Sep 2026 13:54:49 +0200 Subject: [PATCH 2/2] Nest the startup file write under extended app start Parent the main-thread plant-cache I/O to the stream-mode extension span so sentry_file's file.write/file.read attach to app.start instead of ui.load. Co-authored-by: Cursor --- lib/app_start.dart | 32 +++++++++++++++++++++++++++----- lib/product_list.dart | 8 +++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/lib/app_start.dart b/lib/app_start.dart index 944c1d3..02093ee 100644 --- a/lib/app_start.dart +++ b/lib/app_start.dart @@ -14,9 +14,10 @@ import 'package:sentry_flutter/sentry_flutter.dart'; /// lifecycle — `getExtendedAppStartSpan()` is the static-lifecycle getter and /// returns null here. /// -/// The extension stays open through feature-flag hydration and the first -/// catalog fetch, then `finishExtendedAppStart()` releases it. If the catalog -/// never arrives, we still finish before the SDK's 30s deadline. +/// The extension stays open through feature-flag hydration, the first-frame +/// cache write, and the first catalog fetch, then `finishExtendedAppStart()` +/// releases it. If the catalog never arrives, we still finish before the SDK's +/// 30s deadline. class AppStart { static const _featureFlagCount = 15; static const _catalogTimeout = Duration(seconds: 20); @@ -53,14 +54,16 @@ class AppStart { /// Runs [work] as a child of the extended app-start span when one is open. /// /// In stream mode the extension is not the active span, so we must pass it - /// as `parentSpan`. If the extension has already ended (or never started — + /// as `parentSpan`. The callback then becomes the active span (zone), so + /// auto-instrumentation such as `sentry_file` nests under it instead of + /// `ui.load`. If the extension has already ended (or never started — /// e.g. web/desktop), [work] runs without opening a stray root span. static Future span( String name, Future Function() work, { String op = 'app.init', }) async { - final parent = SentryFlutter.getExtendedAppStartSpanV2(); + final parent = _extendedParent(); if (parent == null) { return work(); } @@ -70,6 +73,25 @@ class AppStart { }, parentSpan: parent); } + /// Sync counterpart of [span] for first-frame-blocking startup work. + static T spanSync( + String name, + T Function() work, { + String op = 'app.init', + }) { + final parent = _extendedParent(); + if (parent == null) { + return work(); + } + return Sentry.startSpanSync(name, (span) { + span.setAttribute('sentry.op', SentryAttribute.string(op)); + return work(); + }, parentSpan: parent); + } + + static SentrySpanV2? _extendedParent() => + SentryFlutter.getExtendedAppStartSpanV2(); + /// Signals that the first catalog fetch finished (success or failure). static void markCatalogReady() { if (!_catalogReady.isCompleted) { diff --git a/lib/product_list.dart b/lib/product_list.dart index 8a16b80..62bca47 100644 --- a/lib/product_list.dart +++ b/lib/product_list.dart @@ -133,9 +133,15 @@ class _ItemListState extends State { // 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). + // Nested under extended app start so file.write/file.read attach to `app.start` + // instead of the home `ui.load` (sentry_file parents to the active zone span). void _performFileIO() { try { - final size = performFileIODemo(); + final size = AppStart.spanSync( + 'Warm plant cache', + performFileIODemo, + op: 'file.io', + ); if (kDebugMode) { print('File I/O on main thread, size: $size');