Skip to content

Commit 72f26b3

Browse files
committed
perf(startup): instrument startup phases
1 parent 5530d4c commit 72f26b3

7 files changed

Lines changed: 195 additions & 11 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import 'dart:developer' as developer;
2+
3+
enum StartupPhase {
4+
bindingInitialized,
5+
wizardChecked,
6+
wizardCompleted,
7+
logsInitialized,
8+
pendingChoicesRead,
9+
reportInitialized,
10+
nativeInitialized,
11+
locatorInitialized,
12+
pendingChoicesApplied,
13+
settingsLoaded,
14+
workmanagerCancelled,
15+
migrationCompleted,
16+
loading,
17+
packageLoaded,
18+
walletsChecked,
19+
legacyResolved,
20+
credentialsResolved,
21+
torCheckCompleted,
22+
terminalResolved,
23+
}
24+
25+
typedef StartupPhaseSink =
26+
void Function(StartupPhase phase, Map<String, Object> arguments);
27+
28+
class StartupPhaseTracker {
29+
final StartupPhaseSink _sink;
30+
StartupPhase? _lastPhase;
31+
32+
StartupPhaseTracker({StartupPhaseSink? sink}) : _sink = sink ?? _timelineSink;
33+
34+
StartupPhase? get lastPhase => _lastPhase;
35+
36+
void mark(StartupPhase phase) {
37+
if (_lastPhase != null && phase.index <= _lastPhase!.index) return;
38+
39+
_lastPhase = phase;
40+
try {
41+
_sink(phase, {'phase': phase.name});
42+
} catch (_) {
43+
// Startup instrumentation must never affect startup itself.
44+
}
45+
}
46+
47+
static void _timelineSink(StartupPhase phase, Map<String, Object> arguments) {
48+
developer.Timeline.instantSync('bull.startup.phase', arguments: arguments);
49+
}
50+
}

lib/features/app_startup/app_startup_locator.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:bb_mobile/core/seed/data/repository/seed_repository.dart';
44
import 'package:bb_mobile/core/settings/data/settings_repository.dart';
55
import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/key_value_storage_datasource.dart';
66
import 'package:bb_mobile/core/utils/constants.dart';
7+
import 'package:bb_mobile/core/utils/startup_phase_tracker.dart';
78
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
89
import 'package:bb_mobile/features/app_startup/data/wallet_startup_adapter.dart';
910
import 'package:bb_mobile/features/app_startup/domain/app_startup_wallet_port.dart';
@@ -20,7 +21,7 @@ import 'package:get_it/get_it.dart';
2021
import 'package:bull_tor/tor.dart';
2122

2223
class AppStartupLocator {
23-
static void setup(GetIt locator) {
24+
static void setup(GetIt locator, {StartupPhaseTracker? startupPhaseTracker}) {
2425
locator.registerLazySingleton<AppStartupWalletPort>(
2526
() => WalletStartupAdapter(locator<WalletRepository>()),
2627
);
@@ -69,6 +70,7 @@ class AppStartupLocator {
6970
checkLegacyInstallUsecase: locator<CheckLegacyInstallUsecase>(),
7071
checkBackupUsecase: locator<CheckBackupUsecase>(),
7172
initializeRequiredTorUsecase: locator<InitializeRequiredTorUsecase>(),
73+
startupPhaseTracker: startupPhaseTracker,
7274
),
7375
);
7476
}

lib/features/app_startup/presentation/bloc/app_startup_bloc.dart

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:async';
33
import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/keychain_locked_exception.dart';
44
import 'package:bb_mobile/core/utils/logger.dart';
55
import 'package:bb_mobile/core/utils/result.dart';
6+
import 'package:bb_mobile/core/utils/startup_phase_tracker.dart';
67
import 'package:bb_mobile/features/app_startup/domain/usecases/check_for_existing_default_wallets_usecase.dart';
78
import 'package:bb_mobile/features/app_startup/domain/usecases/check_legacy_install_usecase.dart';
89
import 'package:bb_mobile/features/app_startup/domain/usecases/initialize_required_tor_usecase.dart';
@@ -30,7 +31,9 @@ class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState>
3031
required this._checkLegacyInstallUsecase,
3132
required this._checkBackupUsecase,
3233
required this._initializeRequiredTorUsecase,
34+
StartupPhaseTracker? startupPhaseTracker,
3335
}) : super(const AppStartupState.initial()) {
36+
_startupPhaseTracker = startupPhaseTracker ?? StartupPhaseTracker();
3437
on<AppStartupStarted>(_onAppStartupStarted);
3538
WidgetsBinding.instance.addObserver(this);
3639
}
@@ -42,6 +45,7 @@ class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState>
4245
final CheckLegacyInstallUsecase _checkLegacyInstallUsecase;
4346
final CheckBackupUsecase _checkBackupUsecase;
4447
final InitializeRequiredTorUsecase _initializeRequiredTorUsecase;
48+
late final StartupPhaseTracker _startupPhaseTracker;
4549

4650
/// True while we're sitting on the splash because a startup step
4751
/// threw `KeychainLockedException` (iOS pre-first-unlock pre-warm).
@@ -69,28 +73,37 @@ class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState>
6973
Emitter<AppStartupState> emit,
7074
) async {
7175
emit(const AppStartupState.loadingInProgress());
76+
_startupPhaseTracker.mark(StartupPhase.loading);
7277

7378
try {
7479
// Log app version on startup
7580
final packageInfo = await PackageInfo.fromPlatform();
7681
log.info(
7782
'App started: ${packageInfo.appName} v${packageInfo.version}+${packageInfo.buildNumber}',
7883
);
84+
_startupPhaseTracker.mark(StartupPhase.packageLoaded);
7985

8086
final doDefaultWalletsExist = await _checkForExistingDefaultWalletsUsecase
8187
.execute();
88+
_startupPhaseTracker.mark(StartupPhase.walletsChecked);
8289

8390
// Pre-v5 ("BULL") installs are no longer migrated: gate them behind a
8491
// backup screen. Only when the new DB is empty — the legacy marker can
8592
// survive a failed migration while the user has since set up working
8693
// v5+ wallets, and those current seeds are not legacy-format: gating
8794
// such an install would show a backup screen missing its live wallets
8895
// and instruct deleting them.
89-
if (!doDefaultWalletsExist &&
90-
await _checkLegacyInstallUsecase.execute()) {
91-
log.warning('Legacy (pre-v5) install detected — backup gate shown');
92-
emit(const AppStartupState.legacyBackupRequired());
93-
return;
96+
if (!doDefaultWalletsExist) {
97+
final isLegacyInstall = await _checkLegacyInstallUsecase.execute();
98+
_startupPhaseTracker.mark(StartupPhase.legacyResolved);
99+
if (isLegacyInstall) {
100+
log.warning('Legacy (pre-v5) install detected — backup gate shown');
101+
emit(const AppStartupState.legacyBackupRequired());
102+
_startupPhaseTracker.mark(StartupPhase.terminalResolved);
103+
return;
104+
}
105+
} else {
106+
_startupPhaseTracker.mark(StartupPhase.legacyResolved);
94107
}
95108

96109
bool isPinCodeSet = false;
@@ -99,6 +112,7 @@ class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState>
99112
switch (await _checkPinCodeExistsUsecase.execute()) {
100113
case Ok(:final value):
101114
isPinCodeSet = value;
115+
_startupPhaseTracker.mark(StartupPhase.credentialsResolved);
102116
case Err(failure: AppUnlockKeychainLockedFailure()):
103117
_waitForKeychainUnlock();
104118
return;
@@ -111,18 +125,21 @@ class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState>
111125
// there from a previous install.
112126
// (e.g. secure storage data on iOS like the pin code)
113127
await _resetAppDataUsecase.execute();
128+
_startupPhaseTracker.mark(StartupPhase.credentialsResolved);
114129
}
115130

116131
// Warm the embedded client without delaying the startup screen. The
117132
// coordinator makes this single-flight with any concurrent consumer.
118133
unawaited(_initializeTorInBackground());
119134

135+
_startupPhaseTracker.mark(StartupPhase.torCheckCompleted);
120136
emit(
121137
AppStartupState.success(
122138
isPinCodeSet: isPinCodeSet,
123139
hasDefaultWallets: doDefaultWalletsExist,
124140
),
125141
);
142+
_startupPhaseTracker.mark(StartupPhase.terminalResolved);
126143
} on KeychainLockedException catch (_) {
127144
// iOS pre-first-unlock pre-warm: the keychain is locked, so any
128145
// seed read (CheckForExistingDefaultWalletsUsecase →

lib/locator.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:bb_mobile/core/storage/sqlite_database.dart';
44
import 'package:bb_mobile/core/sync/sync_locator.dart';
55
import 'package:bb_mobile/core/sync/sync_coordinator.dart';
66
import 'package:bb_mobile/core/utils/result.dart';
7+
import 'package:bb_mobile/core/utils/startup_phase_tracker.dart';
78
import 'package:bb_mobile/payjoin_setup.dart';
89
import 'package:bb_mobile/features/address_view/address_view_locator.dart';
910
import 'package:bb_mobile/features/all_seed_view/all_seed_view_locator.dart';
@@ -63,6 +64,7 @@ class AppLocator {
6364
bool startPayjoinRecovery = true,
6465
bool startOrderSwapWatcher = true,
6566
bool startAutoswapWatcher = true,
67+
StartupPhaseTracker? startupPhaseTracker,
6668
}) async {
6769
locator.enableRegisteringMultipleInstancesOfOneType();
6870

@@ -123,7 +125,7 @@ class AppLocator {
123125
TorSettingsLocator.setup(locator);
124126
PinCodeLocator.setup(locator);
125127
WizardLocator.setup(locator);
126-
AppStartupLocator.setup(locator);
128+
AppStartupLocator.setup(locator, startupPhaseTracker: startupPhaseTracker);
127129
AppUnlockLocator.setup(locator);
128130
OnboardingLocator.setup(locator);
129131
AllSeedViewLocator.setup(locator);

lib/main.dart

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:bb_mobile/core/themes/app_theme.dart';
1111
import 'package:bb_mobile/core/utils/constants.dart';
1212
import 'package:bb_mobile/core/utils/logger.dart';
1313
import 'package:bb_mobile/core/utils/report.dart';
14+
import 'package:bb_mobile/core/utils/startup_phase_tracker.dart';
1415

1516
import 'package:bb_mobile/features/app_startup/presentation/bloc/app_startup_bloc.dart';
1617
import 'package:bb_mobile/features/app_startup/ui/app_startup_widget.dart';
@@ -62,8 +63,13 @@ void resumePayjoinsOnAppResume(
6263
}
6364

6465
class Bull {
65-
static Future<void> init({String? payjoinDatabasePath}) async {
66+
static Future<void> init({
67+
String? payjoinDatabasePath,
68+
StartupPhaseTracker? startupPhaseTracker,
69+
}) async {
70+
final tracker = startupPhaseTracker ?? StartupPhaseTracker();
6671
await initLogs();
72+
tracker.mark(StartupPhase.logsInitialized);
6773
// The pre-init wizard writes consent to prefs via the bloc's
6874
// `SavePendingWizardChoicesUsecase` right before this runs. Pull
6975
// it so Sentry initializes with the user's freshest answer rather
@@ -73,18 +79,28 @@ class Bull {
7379
final pending = await ReadPendingWizardChoicesUsecase(
7480
repository: preInitRepo,
7581
).execute();
82+
tracker.mark(StartupPhase.pendingChoicesRead);
7683
await Report.init(wizardConsent: pending?.reportingConsent);
84+
tracker.mark(StartupPhase.reportInitialized);
7785
await initFlutterRustBridgeDependencies();
86+
tracker.mark(StartupPhase.nativeInitialized);
7887
// The Locator setup might depend on the initialization of the libraries above
7988
// so it's important to call it after the initialization
80-
await initLocator(payjoinDatabasePath: payjoinDatabasePath);
89+
await initLocator(
90+
payjoinDatabasePath: payjoinDatabasePath,
91+
startupPhaseTracker: tracker,
92+
);
93+
tracker.mark(StartupPhase.locatorInitialized);
8194
// Flush wizard pending values (if any) to SQLite now that the
8295
// settings repository is available, then mark the wizard complete.
8396
await locator<ApplyPendingWizardChoicesUsecase>().execute();
97+
tracker.mark(StartupPhase.pendingChoicesApplied);
8498
final settings = locator<SettingsRepository>();
8599
Report.consent = (await settings.fetch()).isErrorReportingEnabled;
100+
tracker.mark(StartupPhase.settingsLoaded);
86101
if (Platform.isAndroid || Platform.isIOS) {
87102
await initWorkmanager();
103+
tracker.mark(StartupPhase.workmanagerCancelled);
88104
}
89105
// Emits the install/upgrade transition event (no-op on a normal
90106
// launch) and advances the persisted version marker. The shout is
@@ -96,6 +112,7 @@ class Bull {
96112
await log.shout(message: type.name, category: ReportCategory.migration);
97113
await Report.commitVersion();
98114
}
115+
tracker.mark(StartupPhase.migrationCompleted);
99116
}
100117

101118
static Future<void> initFlutterRustBridgeDependencies() async {
@@ -135,11 +152,15 @@ class Bull {
135152
}
136153
}
137154

138-
static Future<void> initLocator({String? payjoinDatabasePath}) async {
155+
static Future<void> initLocator({
156+
String? payjoinDatabasePath,
157+
StartupPhaseTracker? startupPhaseTracker,
158+
}) async {
139159
await AppLocator.setup(
140160
locator,
141161
SqliteDatabase(),
142162
payjoinDatabasePath: payjoinDatabasePath,
163+
startupPhaseTracker: startupPhaseTracker,
143164
);
144165
Bloc.observer = AppBlocObserver();
145166
}
@@ -158,6 +179,8 @@ Future main() async {
158179
() async {
159180
try {
160181
WidgetsFlutterBinding.ensureInitialized();
182+
final startupPhaseTracker = StartupPhaseTracker();
183+
startupPhaseTracker.mark(StartupPhase.bindingInitialized);
161184
// Wizard runs BEFORE `Bull.init` for everyone — fresh installs
162185
// and upgrades alike — so consent is collected before
163186
// migrations / Sentry init / Drift schema work fires off, and
@@ -167,12 +190,14 @@ Future main() async {
167190
final isComplete = await IsWizardCompleteUsecase(
168191
repository: preInitRepo,
169192
).execute();
193+
startupPhaseTracker.mark(StartupPhase.wizardChecked);
170194
if (!isComplete) {
171195
final completer = Completer<void>();
172196
runApp(WizardApp(onDone: (_) => completer.complete()));
173197
await completer.future;
174198
}
175-
await Bull.init();
199+
startupPhaseTracker.mark(StartupPhase.wizardCompleted);
200+
await Bull.init(startupPhaseTracker: startupPhaseTracker);
176201
} catch (error, stackTrace) {
177202
log.severe(message: 'App Init Error', error: error, trace: stackTrace);
178203
runApp(AppInitErrorScreen(error: error));
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import 'package:bb_mobile/core/utils/startup_phase_tracker.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
4+
void main() {
5+
test('emits each phase once and ignores regressions', () {
6+
final phases = <StartupPhase>[];
7+
final payloads = <Map<String, Object>>[];
8+
final tracker = StartupPhaseTracker(
9+
sink: (phase, arguments) {
10+
phases.add(phase);
11+
payloads.add(arguments);
12+
},
13+
);
14+
15+
tracker
16+
..mark(StartupPhase.nativeInitialized)
17+
..mark(StartupPhase.nativeInitialized)
18+
..mark(StartupPhase.logsInitialized)
19+
..mark(StartupPhase.locatorInitialized);
20+
21+
expect(phases, [
22+
StartupPhase.nativeInitialized,
23+
StartupPhase.locatorInitialized,
24+
]);
25+
expect(payloads, [
26+
{'phase': 'nativeInitialized'},
27+
{'phase': 'locatorInitialized'},
28+
]);
29+
expect(tracker.lastPhase, StartupPhase.locatorInitialized);
30+
});
31+
}

0 commit comments

Comments
 (0)