Skip to content
Back to archipelago.banuacoder.com

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

App launch time tracker with native platform timing, and an implementation that pushes consent-gated OTLP spans. It ships in release builds.

Variables ​

VariableTypeDefaultDescription
appNamestring—The name of your application
isForMonorepobooleantrueWhether 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 ​

bash
archipelago generate launch_tracker_sdk

Non-interactive (CI) ​

bash
archipelago generate launch_tracker_sdk --config my_config.json

Config Template ​

json
{
  "@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 ​

text
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           # AppLaunchTrackerImplPackageModule

Two-Phase Launch Lifecycle ​

The tracker is designed around the monorepo's two-phase init architecture:

MethodPhaseDescription
start()Pre-launchCalled in HostAppWidget.initState, before pre-launch runs — fetches native timestamps via platform channel, starts the monotonic stopwatch
markPreLaunchCompleted()Pre-launchCalled after initializerController.initialize() resolves
markFirstFrameRendered()Post frameCalled in addPostFrameCallback after first frame
startPostLaunch()Post-launchMarks the start of post-launch
markPostLaunchCompleted()Post-launchCalled 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 entryFirst statement of the host bootstrap(); records dart_main
removeNativeSplash(remove) (top-level)AnyWraps FlutterNativeSplash.remove for apps that keep the splash with preserve; records native_splash_remove
traceSpan(name, block, {phase, mode, group, statusOf})AnyRecords block as a span of the launch trace (used by the initializer runner)
recordStep(String step)AnyRecords a named milestone timestamp
recordDuration(String name, int ms)AnyRecords a named duration (e.g. from traceAsync)
seedNativeTimestamps({required int nativeStartMs, required int hostStartMs})TestingInjects 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:

MethodWhat it does
getStartupTimestampsReturns the milestones known so far. Called at start().
reportInteractiveCalls 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 ​

MarkerAndroidiOSAccuracy
launch_requestedProcess.getStartRequestedUptimeMillis() (API 33+)—Exact (system time of the request)
process_startProcess.getStartUptimeMillis()sysctl p_starttime (wall clock, converted with the pair)Android exact; iOS ms resolution
native_splash_shown= launch_requested, else process_start= process_startThe system draws the splash; no app callback exists. The real moment is a few ms after the request
native_app_init_startLaunchTimeTrackerInitProvider.onCreate (merged provider, initOrder 1000)LaunchTimeTrackerPlugin.markAppInitStart() at the top of didFinishLaunchingWithOptionsAndroid: before the app's other providers and Application.onCreate
native_app_init_endFront-of-queue post from the provider: runs when bindApplication (incl. Application.onCreate) returnsdidFinishLaunchingNotification, observed from markAppInitStart()Exact
engine_attachonAttachedToEngine (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_mainmarkDartMain()sameExact (Dart clock)
native_first_frameFlutterUiDisplayListener.onFlutterUiDisplayeddisplayingFlutterUI minus Flutter's 0.2 s splash fadeAndroid 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_frameAs above
native_splash_exit_endnot 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_removeremoveNativeSplash()sameExact (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: warm when 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. Otherwise cold. When the process did not start for this launch, the process milestones are left out and the trace starts at engine attach.
  • iOS: prewarm when ActivePrewarm=1 (the system started the process ahead of the tap; process start is left out), otherwise cold.

Host code ​

  • Android: none. The provider is merged from the plugin's manifest. MainActivity stays 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:
swift
// 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() calls markDartMain() 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:

dart
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 runInitializer records one span per initializer, child of its phase span, with mode (sequential or parallel), group (the parallel batch id, pre_launch.parallel or post_launch.parallel), blocksFirstFrame (true for every phase before post-launch) and status (ok, failed, timeout). A degradable failure that the runner swallows is still recorded as failed.
  • 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.markers returns every marker by key. Since 3.2.0, keys without a field of their own are kept in otherMarkers and round-trip through JSON, so readers show markers that a newer recorder adds.
  • Native step spans (3.3.0): native_app_init, native_splash and native_splash_exit (iOS), children of the native phase span.
  • Launch type (3.3.0): cold, warm or prewarm.
  • Clock: one monotonic Stopwatch, microsecond resolution. The wall clock is read once, at start(), 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 (origin says 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
dart
// 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: 1

Recording 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 ​

json
{
  "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 nameAttributes
app_launchParent span wrapping all phases
native_to_hostduration_ms, phase: native_to_host, flavor, platform
pre_launchduration_ms, phase: pre_launch
first_frameduration_ms, phase: first_frame
post_launchduration_ms, phase: post_launch

Trace Utilities ​

trace_utils.dart provides wrapping helpers:

dart
// 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:

dart
@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.

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 12 SplashScreen exit listener, because that replaces the system animation and resets system bar colours.
  • The iOS native_first_frame is derived from Flutter's 0.2 s splash fade.
  • Engine creation start and dart_entrypoint_run are 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 on FlutterActivity (rather than FlutterFragmentActivity) already reports at the first Flutter frame, so the Play Console's time to full display there is the first frame, not interactive.
  • 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.
  • HostAppWidget resolves AppLaunchTracker unconditionally. If the module isn't wired at all (post_gen.dart failed silently — fixed in 2.2.2, now hard-fails instead), the app crashes on first launch with a GetIt unregistered-type error.
  • channel/monitoring constructor params are test-only overrides (@ignoreParam); LaunchTimeTrackerChannel has no standalone DI registration of its own — it's only ever constructed internally by AppLaunchTrackerImplPackageModule.

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.