Infrastructure SDKs
launch_tracker_sdk Free
- Tier
- Freeversion 3.3.5
- Command
archipelago generate launch_tracker_sdk- Generates
infrastructure/app_launch_tracker_impl
- Depends on
- monitoring_sdkgenerate these first
- Used by
- app_scaffold
App launch time tracker with native platform timing, and an implementation that pushes consent-gated OTLP spans. It ships in release builds.
Variables
| Variable | Type | Default | Description |
|---|---|---|---|
| appName | string | — | The name of your application |
| isForMonorepo | boolean | true | Whether this is being generated as part of a monorepo |
Usage
Automatic (via flutter_modular_monorepo)
Included unconditionally in Phase 3 of the monorepo post-gen hook — no selection required.
Standalone
archipelago generate launch_tracker_sdkNon-interactive (CI)
archipelago generate launch_tracker_sdk --config my_config.jsonConfig Template
{
"@appName": "The name of your application",
"appName": "MyApp",
"@isForMonorepo": "Whether this is being generated as part of a monorepo",
"isForMonorepo": true
}Architecture
A single app_launch_tracker_impl package, no _api. Since 3.0.0 it ships in release builds: there is no app_launch_tracker_noop and no build_prepare mapping. The host app is its only consumer: HostAppWidget, the initializer runner, and the host initializers that adapt it to other packages' ports: LaunchTraceSourceInitializer with the Blackbox recorder, and LaunchTraceFeedInitializer with app_debugger. No feature package imports the tracker.
Generated Structure
infrastructure/
└── app_launch_tracker_impl/ # Implementation (ships in release)
├── android/src/main/
│ ├── AndroidManifest.xml # Merges LaunchTimeTrackerInitProvider
│ └── kotlin/com/archipelago/app_launch_tracker/
│ └── LaunchTimeTrackerPlugin.kt # Native milestones + launch type (Android)
├── ios/
│ └── Classes/
│ └── LaunchTimeTrackerPlugin.swift # Native milestones + launch type (iOS)
└── lib/src/
├── app_launch_tracker_impl.dart # AppLaunchTracker
├── app_launch_metrics.dart # AppLaunchMetrics + MetricTrace (package-private)
├── launch_trace.dart # LaunchTrace v1: LaunchSpan, phases, JSON
├── launch_time_tracker_channel.dart # Platform channel wrapper
├── trace_utils.dart # traceAsync / traceSync / safeTraceProcessAsync
└── di/
├── injector.dart # @InjectableInit.microPackage()
└── injector.module.dart # AppLaunchTrackerImplPackageModuleTwo-Phase Launch Lifecycle
The tracker is designed around the monorepo's two-phase init architecture:
| Method | Phase | Description |
|---|---|---|
start() | Pre-launch | Called in HostAppWidget.initState, before pre-launch runs — fetches native timestamps via platform channel, starts the monotonic stopwatch |
markPreLaunchCompleted() | Pre-launch | Called after initializerController.initialize() resolves |
markFirstFrameRendered() | Post frame | Called in addPostFrameCallback after first frame |
startPostLaunch() | Post-launch | Marks the start of post-launch |
markPostLaunchCompleted() | Post-launch | Called after PostLaunchInitializer.initialize() — stops recording, pushes OTLP spans, reports interactive to the platform, and finalizes the LaunchTrace once the platform answers (capped at 2 s) |
markDartMain() (top-level) | Dart entry | First statement of the host bootstrap(); records dart_main |
removeNativeSplash(remove) (top-level) | Any | Wraps FlutterNativeSplash.remove for apps that keep the splash with preserve; records native_splash_remove |
traceSpan(name, block, {phase, mode, group, statusOf}) | Any | Records block as a span of the launch trace (used by the initializer runner) |
recordStep(String step) | Any | Records a named milestone timestamp |
recordDuration(String name, int ms) | Any | Records a named duration (e.g. from traceAsync) |
seedNativeTimestamps({required int nativeStartMs, required int hostStartMs}) | Testing | Injects pre-captured timestamps (used in tests and by the platform channel callback) |
Native Timing
app_launch_tracker_impl is a Flutter plugin on the channel com.archipelago/launch_time_tracker:
| Method | What it does |
|---|---|
getStartupTimestamps | Returns the milestones known so far. Called at start(). |
reportInteractive | Calls Activity.reportFullyDrawn() (Android, API 29+) or emits an interactive os_signpost (iOS), then returns the milestones. Called at markPostLaunchCompleted(). |
Both return monoNowMs and epochNowMs (one paired sample), launchType and markers (name to monotonic ms). Native code never mixes clocks: every time is monotonic (Android System.nanoTime, the CLOCK_MONOTONIC base of uptimeMillis and Process.getStart*; iOS CACurrentMediaTime). Dart converts each one once, through the pair, onto the trace timeline.
Milestones per platform
| Marker | Android | iOS | Accuracy |
|---|---|---|---|
launch_requested | Process.getStartRequestedUptimeMillis() (API 33+) | — | Exact (system time of the request) |
process_start | Process.getStartUptimeMillis() | sysctl p_starttime (wall clock, converted with the pair) | Android exact; iOS ms resolution |
native_splash_shown | = launch_requested, else process_start | = process_start | The system draws the splash; no app callback exists. The real moment is a few ms after the request |
native_app_init_start | LaunchTimeTrackerInitProvider.onCreate (merged provider, initOrder 1000) | LaunchTimeTrackerPlugin.markAppInitStart() at the top of didFinishLaunchingWithOptions | Android: before the app's other providers and Application.onCreate |
native_app_init_end | Front-of-queue post from the provider: runs when bindApplication (incl. Application.onCreate) returns | didFinishLaunchingNotification, observed from markAppInitStart() | Exact |
engine_attach | onAttachedToEngine (inside the FlutterEngine constructor) | register(with:) (at scene connection, after didFinishLaunching: the UIScene lifecycle creates the engine with the first scene) | End of engine creation |
dart_main | markDartMain() | same | Exact (Dart clock) |
native_first_frame | FlutterUiDisplayListener.onFlutterUiDisplayed | displayingFlutterUI minus Flutter's 0.2 s splash fade | Android exact; iOS derived from Flutter's fade constant |
native_splash_exit_start | = native_first_frame (Flutter holds the activity's first draw until its first frame) | = native_first_frame | As above |
native_splash_exit_end | not measured (system animation) | FlutterViewController.displayingFlutterUI (0.2 s after the first frame; reportInteractive waits for it, at most 1.5 s) | iOS exact |
native_splash_remove | removeNativeSplash() | same | Exact (Dart clock) |
flutter_engine_create_start/_end and dart_entrypoint_run are reserved and not emitted: the generated host does not own engine creation (FlutterFragmentActivity and the storyboard's FlutterViewController create it).
Launch type
- Android:
warmwhen the process started for something else (a broadcast, service or push; importance not foreground when the provider ran), when this engine is not the first in the process, or when the first Flutter activity restored saved state. Otherwisecold. When the process did not start for this launch, the process milestones are left out and the trace starts at engine attach. - iOS:
prewarmwhenActivePrewarm=1(the system started the process ahead of the tap; process start is left out), otherwisecold.
Host code
- Android: none. The provider is merged from the plugin's manifest.
MainActivitystays yours to customise. - iOS: the host bricks add one guarded call to
AppDelegate, in two marker regions. It compiles to nothing when the plugin is not installed:
// archipelago:launch_tracker:import:start
#if canImport(app_launch_tracker_impl)
import app_launch_tracker_impl
#endif
// archipelago:launch_tracker:import:end
// first lines of application(_:didFinishLaunchingWithOptions:)
// archipelago:launch_tracker:app_init:start
#if canImport(app_launch_tracker_impl)
LaunchTimeTrackerPlugin.markAppInitStart()
#endif
// archipelago:launch_tracker:app_init:end- Dart:
bootstrap()callsmarkDartMain()first.
Apps that keep the splash
If you hold the native splash with FlutterNativeSplash.preserve(widgetsBinding: ...), remove it through the tracker so the trace shows when the app let go:
removeNativeSplash(FlutterNativeSplash.remove);preserve holds Flutter's first frame, so native_first_frame and the splash exit markers follow on the next frame.
Launch Trace (v1)
Since 3.1.0 the tracker records a structured timeline of the launch, the LaunchTrace. It answers what the phase totals cannot: when each step started, which steps ran in parallel and which waited in a queue, which steps delayed the first frame, and which sub-steps make up a slow initializer.
- Phase spans:
native(process start to engine attach),engine(engine attach to tracker start: Dart VM boot,main(), bootstrap, DI),pre_launch,first_frame,post_launch. Each is a root span whose id is the phase name. - Initializer spans: the host's
runInitializerrecords one span per initializer, child of its phase span, withmode(sequentialorparallel),group(the parallel batch id,pre_launch.parallelorpost_launch.parallel),blocksFirstFrame(true for every phase before post-launch) andstatus(ok,failed,timeout). A degradable failure that the runner swallows is still recorded asfailed. - Sub-steps: code inside an initializer calls
traceLaunchStep. It finds its parent through the zone the runner set up, so it needs no DI lookup, and does nothing outside a launch span. - Markers:
process_start,engine_attach,first_frame,interactive(post-launch finished), and since 3.3.0 the native milestones above.trace.markersreturns every marker by key. Since 3.2.0, keys without a field of their own are kept inotherMarkersand round-trip through JSON, so readers show markers that a newer recorder adds. - Native step spans (3.3.0):
native_app_init,native_splashandnative_splash_exit(iOS), children of thenativephase span. - Launch type (3.3.0):
cold,warmorprewarm. - Clock: one monotonic
Stopwatch, microsecond resolution. The wall clock is read once, atstart(), to line up with the native times. Offsets are from the launch request (Android 13+), else process start, falling back to engine attach, then Dart start (originsays which).
A full launch
A cold Android 13 launch with every native milestone. The splash leaves at the first Flutter frame (the pre-launch loading UI); the app content's first_frame comes after pre-launch.
gantt
title Cold launch, Android 13 (ms from launch request)
dateFormat x
axisFormat %L
section Native
splash visible :active, 0, 472
process start :milestone, 38, 0
native app init :done, 52, 96
native (to engine attach) :done, 38, 215
section Engine and Dart
engine and bootstrap :done, 215, 424
Dart main :milestone, 331, 0
section Pre-launch
pre-launch initializers :424, 713
native first frame (loading) :milestone, 472, 0
section First frame
first frame of content :713, 743
section Post-launch
post-launch initializers :744, 1312
interactive :milestone, 1312, 0
// Inside any initializer's initialize():
await traceLaunchStep('openDatabase', () => database.open());
// Consumers (debugger Launch tool, Blackbox):
final tracker = getIt<AppLaunchTracker>();
tracker.lastLaunchTrace; // null until post-launch completes
tracker.launchTraces.listen(...); // broadcast, emits once
tracker.launchTraceSnapshot(); // the trace so far, e.g. after a pre-launch failure
jsonEncode(trace.toJson()); // schemaVersion: 1Recording costs one small record, one zone and one closure per span, with no logging. It stops when the trace is final and after 512 spans. The trace stays in memory; the monitoring spans below are unchanged.
JSON shape
{
"schemaVersion": 1,
"origin": "process_start",
"originEpochMs": 1791646373000,
"launchType": null,
"markers": {"process_start": 0, "engine_attach": 182.4, "first_frame": 742.9, "interactive": 1311.6},
"spans": [
{"id": "pre_launch", "parentId": null, "name": "pre_launch", "phase": "pre_launch",
"startOffsetMs": 423.5, "durationMs": 289.2, "mode": "sequential", "group": null,
"blocksFirstFrame": true, "status": "ok"},
{"id": "3", "parentId": "pre_launch", "name": "SessionInitializer", "phase": "pre_launch",
"startOffsetMs": 520.8, "durationMs": 120.4, "mode": "parallel", "group": "pre_launch.parallel",
"blocksFirstFrame": true, "status": "ok"},
{"id": "7", "parentId": "post_launch", "name": "_FeatureFlagPostLaunchInitializer", "phase": "post_launch",
"startOffsetMs": 760.3, "durationMs": 550.9, "mode": "parallel", "group": "post_launch.parallel",
"blocksFirstFrame": false, "status": "timeout"}
]
}Offsets and durations are fractional milliseconds. launchType (cold or warm) is part of the schema but the native plugins do not report it yet, so it is null. Readers ignore unknown fields; a breaking change bumps schemaVersion.
A launch as a waterfall
gantt
title Cold launch (ms from process start)
dateFormat x
axisFormat %L
section Blocks first frame
native :done, 0, 182
engine and bootstrap :done, 182, 424
DatabaseInitializer (seq) :424, 520
SessionInitializer (par) :521, 641
ThemeInitializer (par) :521, 565
Feature SDK sweep (seq) :641, 713
first frame :milestone, 743, 0
section After first frame
Analytics (par) :760, 971
FeatureFlags (par, timeout) :crit, 760, 1311
interactive :milestone, 1312, 0
Sequential initializers sit end to start; a parallel batch shares one start time. Everything above the first-frame marker delayed the first frame.
In the app, the app_debugger Launch tool draws this trace as a waterfall and a flame view, highlights the critical path, and compares the last 5 launches. Neither package depends on the other: the debugger owns a LaunchTraceFeed port, and its hook adds a host initializer that registers an adapter over AppLaunchTracker (app_debugger 1.15.0 and later). Either install order works: this brick's post_gen writes the same host initializer when features/app_debugger/app_debugger_api exists, and app_debugger does it when the tracker exists. In a project that already has the debugger, run this brick again to connect them.
See it in the Blackbox viewer
For a plain-English guide to the waterfall, the critical path and the native milestones, see Read the launch timeline.
With app_blackbox_recorder 1.18.0 or later, every recorded session carries this trace as launch_trace.json. Neither package depends on the other: Blackbox owns a LaunchTraceSource port, and the recorder's hook adds a host initializer that registers an adapter over AppLaunchTracker (1.19.0 and later). Install this brick before the recorder, or run the recorder again afterwards, to get the trace. The Blackbox viewer draws it as a waterfall (the default), a flame view and a ranked table, with the critical path highlighted. See The Launch view.
OTLP Push via AppMonitoring
When markPostLaunchCompleted() is called, the impl pushes timing spans to Grafana (or any configured OTLP backend) via AppMonitoring.recordTransaction. The impl soft-checks GetIt.instance.isRegistered<AppMonitoring>() — if monitoring is not registered, the push is skipped silently.
Spans emitted:
| Span name | Attributes |
|---|---|
app_launch | Parent span wrapping all phases |
native_to_host | duration_ms, phase: native_to_host, flavor, platform |
pre_launch | duration_ms, phase: pre_launch |
first_frame | duration_ms, phase: first_frame |
post_launch | duration_ms, phase: post_launch |
Trace Utilities
trace_utils.dart provides wrapping helpers:
// Wrap an async operation — records to tracker and prints to debug console
final result = await traceAsync('load_config', () => loadConfig());
// Wrap a sync operation
final value = traceSync('parse_response', () => parseResponse(data));
// Wrap an async operation and emit an OTLP span if monitoring is available
final result = await safeTraceProcessAsync(
key: 'fetch_remote_config',
block: () => remoteConfig.fetchAndActivate(),
monitoring: getIt<AppMonitoring>(),
);safeTraceProcessAsync is a fire-and-forget-safe wrapper: if monitoring is null, it simply executes the block without any tracing overhead.
DI Registration
The package registers AppLaunchTrackerImplPackageModule into the host app's injector.
Register it as the first external module so it's available before any other SDK initializes:
@InjectableInit(
externalPackageModulesBefore: [
ExternalModule(AppLaunchTrackerImplPackageModule),
// ... other modules
],
)
Future<void> initializeInjector() => $initGetIt(getIt);HostAppWidget resolves the tracker via GetIt.I<AppLaunchTracker>() immediately on state creation, starting the clock before the first frame.
Release builds, consent and quotas
build_prepare.yaml has no tracker mapping, so build-prepare --flavor production keeps app_launch_tracker_impl and production builds measure cold launches.
Everything the tracker exports goes through AppMonitoring.recordTransaction (the app_launch span and its children, and safeTraceProcessAsync for initializers). The DI-bound AppMonitoring is ConsentAwareMonitoring, which reports isEnabled == false and drops the call until the user grants diagnostics consent; the tracker returns early when monitoring is disabled. A test in the package asserts no span is sent while monitoring is disabled. Timings are also printed with debugPrint and kept in memory; nothing else leaves the device.
Span volume: one app_launch span with up to four child spans, plus one span per initializer, for every launch of every consenting user. That counts against vendor transaction quotas (Sentry) and Grafana trace storage. Lower tracesSampleRate in the vendor options if the volume is too high.
Known Limitations
- Android does not measure the splash exit animation (
native_splash_exit_end): the tracker does not take over the Android 12SplashScreenexit listener, because that replaces the system animation and resets system bar colours. - The iOS
native_first_frameis derived from Flutter's 0.2 s splash fade. - Engine creation start and
dart_entrypoint_runare not measured (the host does not own engine creation). - The trace is final shortly after
markPostLaunchCompleted()(once the platform answers, at most 2 s), not inside it. Activity.reportFullyDrawn()counts only once per launch. A host onFlutterActivity(rather thanFlutterFragmentActivity) already reports at the first Flutter frame, so the Play Console's time to full display there is the first frame, notinteractive.- A timed-out initializer keeps running; its span ends at the timeout, and child spans that end after the trace is final are not in it.
HostAppWidgetresolvesAppLaunchTrackerunconditionally. If the module isn't wired at all (post_gen.dartfailed silently — fixed in 2.2.2, now hard-fails instead), the app crashes on first launch with a GetIt unregistered-type error.channel/monitoringconstructor params are test-only overrides (@ignoreParam);LaunchTimeTrackerChannelhas no standalone DI registration of its own — it's only ever constructed internally byAppLaunchTrackerImplPackageModule.
Changelog
Run archipelago brick info launch_tracker_sdk to see the latest published version. Enterprise customers can read the full CHANGELOG.md in the brick source repository.