Startup Profile — deep per-module + HBC + segment timing
Single env-var flag ONEKEY_STARTUP_PROFILE=1 turns on three layers of
measurement at once. When OFF (the default), every measurement path is a
single boolean check — zero observable overhead in production.
What gets measured
| Layer | Log tag | Content | Source |
|---|---|---|---|
| 1. JS modules | [StartupProfile.js] |
Top 200 modules by self-time (total minus time spent in nested requires) + moduleId → path |
apps/mobile/src/startupProfile/index.ts |
| 2. HBC files | [StartupProfile.hbc] |
Main bundle I/O ms + size in bytes; parse+eval is derivable from existing ios.main_entry.evaluated / RN timing |
iOS AppDelegate.swift, Android MainApplication.java |
| 3. Segments | [StartupProfile.seg] |
Per-.seg.hbc total duration + id + key + relative path |
apps/mobile/src/splitBundle/installProdBundleLoader.ts |
How to enable
Method 1 — build-time env var (production-capable)
Export before any of the build commands:
export
# JS bundle: Metro plugin picks it up and prepends
# globalThis.__ONEKEY_STARTUP_PROFILE__ = true
# globalThis.__ONEKEY_MODULE_ID_TO_PATH__ = {...}
# to the main entry bundle.
yarn app:android # or `yarn app:ios` / EAS build command
# iOS native: read from Info.plist key or Xcode scheme env
# Android native: read from BuildConfig.ONEKEY_STARTUP_PROFILE (auto-set by
# build.gradle from the same env var)
For EAS builds, set the env var in eas.json for the build profile you want to profile:
{
"build": {
"profile-startup": {
"extends": "base",
"env": {
"ONEKEY_STARTUP_PROFILE": "1"
}
}
}
}
Then eas build --profile profile-startup --platform android.
Method 2 — Xcode scheme env (iOS dev only)
Xcode → Product → Scheme → Edit Scheme → Run → Arguments → Environment Variables:
ONEKEY_STARTUP_PROFILE = 1
This is picked up by ProcessInfo.processInfo.environment at runtime — works only for debug iOS running from Xcode.
Method 3 — Release iOS via Info.plist
In Info.plist add (or let an xcconfig generate at build time):
<key>ONEKEY_STARTUP_PROFILE</key>
<true/>
Or an xcconfig that reads the env var:
ONEKEY_STARTUP_PROFILE = YES
Ship a profile-enabled TestFlight build to inspect real-device release timings.
What shows up in the log when enabled
Cold-start sequence (Android, ~2.5s to TTI):
[StartupTiming] android.app.on_create.start: +0ms from launch (anchor)
...existing native instrumentation...
[StartupProfile.hbc] android.index.android.bundle: io=35ms size=18900000B (prewarm, at +180ms from launch)
[StartupTiming] main_host.did_start: +318ms from launch (android)
...
[StartupTiming] MMKV contextAtom snapshot pre-read: 10 keys (+121ms)
[StartupProfile.seg] 22ms id=3288 key=seg:nm.@formatjs path=segments/nm._formatjs.seg.hbc
[StartupProfile.seg] 12ms id=3311 key=seg:shared.locale.json.zh_CN.json path=...
[StartupProfile.seg] 230ms id=3021 key=seg:kit.provider.Container.KeylessWalletContainer.KeylessWalletContainer path=...
...
[StartupTiming] main entry evaluated (+1780ms)
[StartupProfile.js] summary: tracked=187 modules timed (>=1ms), sum_self=1524ms, sum_inclusive=3847ms, flushing top 200 by self-time
[StartupProfile.js] self=142ms total=189ms packages/kit/src/provider/Container/PrimeLoginContainer/PrimeLoginContainer.tsx
[StartupProfile.js] self=87ms total=312ms packages/kit/src/states/jotai/contexts/tokenList/atoms.ts
[StartupProfile.js] self=64ms total=64ms node_modules/@walletconnect/core/dist/...
...etc up to 200 modules
Interpreting the numbers
[StartupProfile.js] — module-level
self= time inside this module's own factory body excluding any nestedrequire()calls. This is the actionable signal — a module withself=100msactually costs 100ms of its own code.total= inclusive time — includes time spent in everything this module requires. Useful for identifying "roots" of slow subtrees (e.g../Appmight haveself=5ms total=1600ms).- Only modules ≥ 1ms are recorded; sub-ms is filtered to avoid swamping the log with thousands of near-zero entries.
- The measurement patches Metro's
__r(require), NOT__d(define). Reason: by the time our JS code runs,__dhas already registered all factories with the unwrapped version — too late to wrap.__ris what runs the factory lazily, so wrapping it captures every first-time module load regardless of patch timing.
[StartupProfile.hbc] — main bundle file I/O
iois pure OS-level read time (open → EOF).- We can't directly measure Hermes bytecode parse time from the outside; instead compute
parse+exec = <native ios.main_entry.evaluated> - io. - Pre-warm side effect: after this probe the bundle is in the OS page cache. RN's subsequent load no longer pays I/O. If you want to measure non-warm-cache I/O, disable the probe for that run.
- Android probe is fire-and-forget on a MIN_PRIORITY thread to not stall
Application.onCreate.
[StartupProfile.seg] — segment load
- Single aggregate per segment: combined I/O + Hermes parse + register. Splitting these would need a patch to
@onekeyfe/react-native-split-bundle-loader; for now the aggregate is enough to identify the slowest segments. - The relative-path suffix tells you which file in the segment dir is the culprit.
Cross-platform check: are both sides reading the same flag?
Grep the startup log after enabling:
grep 'StartupProfile' app-latest.log | head -5
Expected: at least one [StartupProfile.js], [StartupProfile.hbc], [StartupProfile.seg] line. If only some show up:
| Missing | Likely cause | Fix |
|---|---|---|
[StartupProfile.js] |
Metro prologue not injected → globalThis.__ONEKEY_STARTUP_PROFILE__ is undefined |
Confirm ONEKEY_STARTUP_PROFILE=1 was set in the env when Metro built the bundle. Check apps/mobile/plugins/index.js buildStartupProfilePrologue() |
[StartupProfile.hbc] |
Native flag not read: Android BuildConfig.ONEKEY_STARTUP_PROFILE is false, or iOS Info.plist key missing |
Android: ensure env var was set at ./gradlew assembleRelease time (not just at Metro time). iOS: check Info.plist or xcconfig |
[StartupProfile.seg] |
The JS-side flag was never set (same as first row) — segment logs reuse the JS flag | Same fix as first row |
Zero-overhead guarantee when disabled
Each layer's code path when ONEKEY_STARTUP_PROFILE is not set:
- JS:
isStartupProfileEnabled()readsglobalThis.__ONEKEY_STARTUP_PROFILE__(undefined→false), function returns, no patching, no allocation. - iOS native:
isStartupProfileEnabled()checks ProcessInfo env and Info.plist, returns false, skips theData(contentsOf:)probe. - Android native:
if (BuildConfig.ONEKEY_STARTUP_PROFILE)— dead-code eliminated at compile time when false. - Segment logs:
if ((globalThis as any).__ONEKEY_STARTUP_PROFILE__ === true)— one identity check per segment load, sub-µs.
Production release bundle overhead when flag is off: ≈ 0. No removal needed, instrumentation can live in the main branch permanently.
Files touched
| File | Purpose |
|---|---|
apps/mobile/src/startupProfile/index.ts |
JS __r patcher + flush |
apps/mobile/index.ts |
Two-line hook to install & flush |
apps/mobile/plugins/index.js |
Metro prologue injection (__ONEKEY_STARTUP_PROFILE__ + moduleId→path map) |
apps/mobile/src/splitBundle/installProdBundleLoader.ts |
Per-segment log |
apps/mobile/ios/AppDelegate.swift |
iOS HBC I/O probe + flag reader |
apps/mobile/android/app/build.gradle |
Expose env var → BuildConfig.ONEKEY_STARTUP_PROFILE |
apps/mobile/android/app/src/main/java/.../MainApplication.java |
Android HBC I/O probe |
Parsing script (quick aggregation)
LOG=path/to/app-latest.log
# JS modules sorted by self-time (what you really want to know)
grep '\[StartupProfile\.js\] self=' "$LOG" | head -30
# Total JS cost of top 20 modules
grep '\[StartupProfile\.js\] self=' "$LOG" \
| head -20 | awk '{sum+=$3} END {print sum}' \
| sed 's/self=//'
# Segment slowest 10
grep '\[StartupProfile\.seg\]' "$LOG" | sort -k2 -n -r | head -10
# HBC I/O vs (ios.main_entry.evaluated - io)
grep 'StartupProfile\.hbc\|ios.main_entry.evaluated\|android.index.android.bundle' "$LOG"
Caveats
- Metro
inline-requirestransform moves top-levelconst X = require(...)to the first use-site. This is why we patch__rrather than__d. But module IDs are the same — patching is safe. - Inclusive time
sum_inclusivewill be much larger thansum_selfbecause every nested require is counted in each parent. Don't add them naively to reason about total time. - Segment split (I/O vs parse) requires patching
@onekeyfe/react-native-split-bundle-loader. Not done yet. The combined number is usually enough for triage. - Dev / debug builds include source maps + extra instrumentation — numbers will be 2-3× higher than Release. Profile Release builds for real baselines.
- Flag is build-time, not runtime: to toggle, rebuild. This is deliberate — zero overhead when off, and avoids the complexity of a Dev Settings toggle + MMKV sync across JS/native at startup.